diff --git a/SECURITY.md b/SECURITY.md index b06b9273..c324ffbf 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,9 +31,9 @@ privacy.sexy adopts a defense in depth strategy to protect users on multiple lay - **Content Security Policies (CSP):** privacy.sexy actively follows security guidelines from the Open Web Application Security Project (OWASP) at strictest level. This approach protects against attacks like Cross Site Scripting (XSS) and data injection. -- **Context Isolation:** - The desktop application isolates different code sections based on their access level. - This separation prevents attackers from introducing harmful code into the app, known as injection attacks. +- **Host System Access Control:** + The desktop application segregates code sections based on their access levels. + This provides a critical defense mechanism, prevents attackers from introducing harmful code into the app, known as injection attacks. ### Update Security and Integrity diff --git a/docs/architecture.md b/docs/architecture.md index 17fdf161..d0d6c881 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -27,13 +27,14 @@ Application uses highly decoupled models & services in different DDD layers: **Domain layer**: - Serves as the system's core and central truth. -- Facilitates communication between the application and presentation layers through the domain model. +- It should be independent of other layers and encapsulate the core business concepts. **Infrastructure layer**: -- Manages technical implementations without dependencies on other layers or domain knowledge. +- Provides technical implementations. +- Depends on the application and domain layers in terms of interfaces and contracts but should not include business logic. -![DDD + vue.js](./../img/architecture/app-ddd.png) +![DDD + vue.js](./../img/architecture/app-ddd.drawio.png) ### Application state diff --git a/docs/presentation.md b/docs/presentation.md index d5cdb281..1ab29b41 100644 --- a/docs/presentation.md +++ b/docs/presentation.md @@ -92,6 +92,8 @@ Shared components include: Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages. +Host system access is strictly controlled. The [`preloader`](./../src/presentation/electron/preload/) isolates logic that interacts with the host system. These functionalities are then securely exposed to the renderer process (Vue application) using context-bridging. [`ApiContextBridge.ts`](./../src/presentation/electron/preload/ContextBridging/ApiContextBridge.ts) handles the configuration of the exposed APIs, ensuring a secure bridge between the Electron and Vue layers. + ## Styles ### Style location diff --git a/img/architecture/app-ddd.drawio b/img/architecture/app-ddd.drawio deleted file mode 100644 index 4df829db..00000000 --- a/img/architecture/app-ddd.drawio +++ /dev/null @@ -1 +0,0 @@ -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 \ No newline at end of file diff --git a/img/architecture/app-ddd.drawio.png b/img/architecture/app-ddd.drawio.png new file mode 100644 index 00000000..e53e0db1 Binary files /dev/null and b/img/architecture/app-ddd.drawio.png differ diff --git a/img/architecture/app-ddd.png b/img/architecture/app-ddd.png deleted file mode 100644 index 2deaa2e2..00000000 Binary files a/img/architecture/app-ddd.png and /dev/null differ diff --git a/src/TypeHelpers.ts b/src/TypeHelpers.ts index 562eb95c..c41522a1 100644 --- a/src/TypeHelpers.ts +++ b/src/TypeHelpers.ts @@ -14,3 +14,35 @@ export type ConstructorArguments = export type FunctionKeys = { [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never; }[keyof T]; + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isNumber(value: unknown): value is number { + return typeof value === 'number'; +} + +export function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +export function isFunction(value: unknown): value is (...args: unknown[]) => unknown { + return typeof value === 'function'; +} + +export function isArray(value: unknown): value is Array { + return Array.isArray(value); +} + +export function isPlainObject( + variable: unknown, +): variable is object & Record { + return Boolean(variable) // the data type of null is an object + && typeof variable === 'object' + && !Array.isArray(variable); +} + +export function isNullOrUndefined(value: unknown): value is (null | undefined) { + return typeof value === 'undefined' || value === null; +} diff --git a/src/application/CodeRunner.ts b/src/application/CodeRunner.ts new file mode 100644 index 00000000..6c08a6d2 --- /dev/null +++ b/src/application/CodeRunner.ts @@ -0,0 +1,7 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export interface CodeRunner { + runCode( + code: string, folderName: string, fileExtension: string, os: OperatingSystem, + ): Promise; +} diff --git a/src/application/Common/CustomError.ts b/src/application/Common/CustomError.ts index b219a945..8a31884d 100644 --- a/src/application/Common/CustomError.ts +++ b/src/application/Common/CustomError.ts @@ -1,3 +1,5 @@ +import { isFunction } from '@/TypeHelpers'; + /* Provides a unified and resilient way to extend errors across platforms. @@ -50,8 +52,3 @@ function ensureStackTrace(target: Error) { } captureStackTrace(target, target.constructor); } - -// eslint-disable-next-line @typescript-eslint/ban-types -function isFunction(func: unknown): func is Function { - return typeof func === 'function'; -} diff --git a/src/application/Common/Enum.ts b/src/application/Common/Enum.ts index b7f37da7..74f42218 100644 --- a/src/application/Common/Enum.ts +++ b/src/application/Common/Enum.ts @@ -1,3 +1,5 @@ +import { isString } from '@/TypeHelpers'; + // Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611 export type EnumType = number | string; export type EnumVariable @@ -23,7 +25,7 @@ function parseEnumValue( if (!value) { throw new Error(`missing ${enumName}`); } - if (typeof value !== 'string') { + if (!isString(value)) { throw new Error(`unexpected type of ${enumName}: "${typeof value}"`); } const casedValue = getEnumNames(enumVariable) @@ -40,7 +42,7 @@ export function getEnumNames ): string[] { return Object .values(enumVariable) - .filter((enumMember) => typeof enumMember === 'string') as string[]; + .filter((enumMember): enumMember is string => isString(enumMember)); } export function getEnumValues( diff --git a/src/application/Parser/DocumentationParser.ts b/src/application/Parser/DocumentationParser.ts index 9d1b0ca3..2a177fb2 100644 --- a/src/application/Parser/DocumentationParser.ts +++ b/src/application/Parser/DocumentationParser.ts @@ -1,4 +1,5 @@ import type { DocumentableData, DocumentationData } from '@/application/collections/'; +import { isString, isArray } from '@/TypeHelpers'; export function parseDocs(documentable: DocumentableData): readonly string[] { const { docs } = documentable; @@ -14,11 +15,9 @@ function addDocs( docs: DocumentationData, container: DocumentationContainer, ): DocumentationContainer { - if (docs instanceof Array) { - if (docs.length > 0) { - container.addParts(docs); - } - } else if (typeof docs === 'string') { + if (isArray(docs)) { + docs.forEach((doc) => container.addPart(doc)); + } else if (isString(docs)) { container.addPart(docs); } else { throwInvalidType(); @@ -29,27 +28,21 @@ function addDocs( class DocumentationContainer { private readonly parts = new Array(); - public addPart(documentation: string) { + public addPart(documentation: unknown): void { if (!documentation) { throw Error('missing documentation'); } - if (typeof documentation !== 'string') { + if (!isString(documentation)) { throwInvalidType(); } this.parts.push(documentation); } - public addParts(parts: readonly string[]) { - for (const part of parts) { - this.addPart(part); - } - } - public getAll(): ReadonlyArray { return this.parts; } } -function throwInvalidType() { +function throwInvalidType(): never { throw new Error('docs field (documentation) must be an array of strings'); } diff --git a/src/application/Parser/NodeValidation/NodeValidator.ts b/src/application/Parser/NodeValidation/NodeValidator.ts index 4c154dbd..3d06932c 100644 --- a/src/application/Parser/NodeValidation/NodeValidator.ts +++ b/src/application/Parser/NodeValidation/NodeValidator.ts @@ -1,3 +1,4 @@ +import { isString } from '@/TypeHelpers'; import { INodeDataErrorContext, NodeDataError } from './NodeDataError'; import { NodeData } from './NodeData'; @@ -13,7 +14,7 @@ export class NodeValidator { 'missing name', ) .assert( - () => typeof nameValue === 'string', + () => isString(nameValue), `Name (${JSON.stringify(nameValue)}) is not a string but ${typeof nameValue}.`, ); } diff --git a/src/application/Parser/Script/Compiler/Function/Call/FunctionCallParser.ts b/src/application/Parser/Script/Compiler/Function/Call/FunctionCallParser.ts index 59c8382e..2e613041 100644 --- a/src/application/Parser/Script/Compiler/Function/Call/FunctionCallParser.ts +++ b/src/application/Parser/Script/Compiler/Function/Call/FunctionCallParser.ts @@ -1,4 +1,5 @@ import type { FunctionCallData, FunctionCallsData, FunctionCallParametersData } from '@/application/collections/'; +import { isArray, isPlainObject } from '@/TypeHelpers'; import { FunctionCall } from './FunctionCall'; import { FunctionCallArgumentCollection } from './Argument/FunctionCallArgumentCollection'; import { FunctionCallArgument } from './Argument/FunctionCallArgument'; @@ -10,13 +11,13 @@ export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] { } function getCallSequence(calls: FunctionCallsData): FunctionCallData[] { - if (typeof calls !== 'object') { - throw new Error('called function(s) must be an object'); + if (!isPlainObject(calls) && !isArray(calls)) { + throw new Error('called function(s) must be an object or array'); } - if (calls instanceof Array) { + if (isArray(calls)) { return calls as FunctionCallData[]; } - const singleCall = calls; + const singleCall = calls as FunctionCallData; return [singleCall]; } diff --git a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts index 5d978111..4a9af7cc 100644 --- a/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts +++ b/src/application/Parser/Script/Compiler/Function/SharedFunctionsParser.ts @@ -6,6 +6,7 @@ import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValida import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoDuplicatedLines } from '@/application/Parser/Script/Validation/Rules/NoDuplicatedLines'; import { ICodeValidator } from '@/application/Parser/Script/Validation/ICodeValidator'; +import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { SharedFunctionCollection } from './SharedFunctionCollection'; import { ISharedFunctionCollection } from './ISharedFunctionCollection'; @@ -121,8 +122,11 @@ function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) { } function ensureExpectedParametersType(functions: readonly FunctionData[]) { + const hasValidParameters = ( + func: FunctionData, + ) => isNullOrUndefined(func.parameters) || isArrayOfObjects(func.parameters); const unexpectedFunctions = functions - .filter((func) => func.parameters && !isArrayOfObjects(func.parameters)); + .filter((func) => !hasValidParameters(func)); if (unexpectedFunctions.length) { const errorMessage = `parameters must be an array of objects in function(s) ${printNames(unexpectedFunctions)}`; throw new Error(errorMessage); @@ -130,8 +134,7 @@ function ensureExpectedParametersType(functions: readonly FunctionData[]) { } function isArrayOfObjects(value: unknown): boolean { - return Array.isArray(value) - && value.every((item) => typeof item === 'object'); + return isArray(value) && value.every((item) => isPlainObject(item)); } function printNames(holders: readonly FunctionData[]) { diff --git a/src/infrastructure/SystemOperations/NodeSystemOperations.ts b/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts similarity index 65% rename from src/infrastructure/SystemOperations/NodeSystemOperations.ts rename to src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts index 6979ec61..88cbf747 100644 --- a/src/infrastructure/SystemOperations/NodeSystemOperations.ts +++ b/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts @@ -1,10 +1,10 @@ -import { tmpdir } from 'os'; -import { join } from 'path'; -import { chmod, mkdir, writeFile } from 'fs/promises'; -import { exec } from 'child_process'; -import { ISystemOperations } from './ISystemOperations'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { chmod, mkdir, writeFile } from 'node:fs/promises'; +import { exec } from 'node:child_process'; +import { SystemOperations } from './SystemOperations'; -export function createNodeSystemOperations(): ISystemOperations { +export function createNodeSystemOperations(): SystemOperations { return { operatingSystem: { getTempDirectory: () => tmpdir(), @@ -33,7 +33,14 @@ export function createNodeSystemOperations(): ISystemOperations { ) => writeFile(filePath, data), }, command: { - execute: (command) => exec(command), + execute: (command) => new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) { + reject(error); + } + resolve(); + }); + }), }, }; } diff --git a/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts b/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts new file mode 100644 index 00000000..0d2d9d1f --- /dev/null +++ b/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts @@ -0,0 +1,24 @@ +export interface SystemOperations { + readonly operatingSystem: OperatingSystemOps; + readonly location: LocationOps; + readonly fileSystem: FileSystemOps; + readonly command: CommandOps; +} + +export interface OperatingSystemOps { + getTempDirectory(): string; +} + +export interface LocationOps { + combinePaths(...pathSegments: string[]): string; +} + +export interface CommandOps { + execute(command: string): Promise; +} + +export interface FileSystemOps { + setFilePermissions(filePath: string, mode: string | number): Promise; + createDirectory(directoryPath: string, isRecursive?: boolean): Promise; + writeToFile(filePath: string, data: string): Promise; +} diff --git a/src/infrastructure/CodeRunner.ts b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts similarity index 65% rename from src/infrastructure/CodeRunner.ts rename to src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts index c6135c7d..5a2714b3 100644 --- a/src/infrastructure/CodeRunner.ts +++ b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts @@ -1,18 +1,19 @@ -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { getWindowInjectedSystemOperations } from './SystemOperations/WindowInjectedSystemOperations'; +import { CodeRunner } from '@/application/CodeRunner'; +import { SystemOperations } from './SystemOperations/SystemOperations'; +import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations'; -export class CodeRunner { +export class TemporaryFileCodeRunner implements CodeRunner { constructor( - private readonly system = getWindowInjectedSystemOperations(), - private readonly environment = RuntimeEnvironment.CurrentEnvironment, + private readonly system: SystemOperations = createNodeSystemOperations(), ) { } - public async runCode(code: string, folderName: string, fileExtension: string): Promise { - const { os } = this.environment; - if (os === undefined) { - throw new Error('Unidentified operating system'); - } + public async runCode( + code: string, + folderName: string, + fileExtension: string, + os: OperatingSystem, + ): Promise { const dir = this.system.location.combinePaths( this.system.operatingSystem.getTempDirectory(), folderName, @@ -22,7 +23,7 @@ export class CodeRunner { await this.system.fileSystem.writeToFile(filePath, code); await this.system.fileSystem.setFilePermissions(filePath, '755'); const command = getExecuteCommand(filePath, os); - this.system.command.execute(command); + await this.system.command.execute(command); } } diff --git a/src/infrastructure/Entity/BaseEntity.ts b/src/infrastructure/Entity/BaseEntity.ts index 90e1a6f7..5c3958cf 100644 --- a/src/infrastructure/Entity/BaseEntity.ts +++ b/src/infrastructure/Entity/BaseEntity.ts @@ -1,8 +1,9 @@ +import { isNumber } from '@/TypeHelpers'; import { IEntity } from './IEntity'; export abstract class BaseEntity implements IEntity { protected constructor(public id: TId) { - if (typeof id !== 'number' && !id) { + if (!isNumber(id) && !id) { throw new Error('Id cannot be null or empty'); } } diff --git a/src/infrastructure/EnvironmentVariables/EnvironmentVariablesValidator.ts b/src/infrastructure/EnvironmentVariables/EnvironmentVariablesValidator.ts index 2f01af12..4b20ae17 100644 --- a/src/infrastructure/EnvironmentVariables/EnvironmentVariablesValidator.ts +++ b/src/infrastructure/EnvironmentVariables/EnvironmentVariablesValidator.ts @@ -1,3 +1,4 @@ +import { isBoolean, isFunction } from '@/TypeHelpers'; import { IEnvironmentVariables } from './IEnvironmentVariables'; /* Validation is externalized to keep the environment objects simple */ @@ -15,7 +16,7 @@ export function validateEnvironmentVariables(environment: IEnvironmentVariables) function getKeysMissingValues(keyValuePairs: Record): string[] { return Object.entries(keyValuePairs) .reduce((acc, [key, value]) => { - if (!value && typeof value !== 'boolean') { + if (!value && !isBoolean(value)) { acc.push(key); } return acc; @@ -38,7 +39,7 @@ function capturePropertyValues(instance: object): Record { // Capture getter properties from the instance's prototype for (const [key, descriptor] of Object.entries(descriptors)) { - if (typeof descriptor.get === 'function') { + if (isFunction(descriptor.get)) { obj[key] = descriptor.get.call(instance); } } diff --git a/src/infrastructure/Log/ElectronLogger.ts b/src/infrastructure/Log/ElectronLogger.ts index 7cbdfb14..c25f68e9 100644 --- a/src/infrastructure/Log/ElectronLogger.ts +++ b/src/infrastructure/Log/ElectronLogger.ts @@ -2,14 +2,8 @@ import log from 'electron-log/main'; import { Logger } from '@/application/Common/Log/Logger'; import type { LogFunctions } from 'electron-log'; -// Using plain-function rather than class so it can be used in Electron's context-bridging. export function createElectronLogger(logger: LogFunctions = log): Logger { - return { - info: (...params) => logger.info(...params), - debug: (...params) => logger.debug(...params), - warn: (...params) => logger.warn(...params), - error: (...params) => logger.error(...params), - }; + return logger; } export const ElectronLogger = createElectronLogger(); diff --git a/src/infrastructure/SystemOperations/ISystemOperations.ts b/src/infrastructure/SystemOperations/ISystemOperations.ts deleted file mode 100644 index 8915d0f5..00000000 --- a/src/infrastructure/SystemOperations/ISystemOperations.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface ISystemOperations { - readonly operatingSystem: IOperatingSystemOps; - readonly location: ILocationOps; - readonly fileSystem: IFileSystemOps; - readonly command: ICommandOps; -} - -export interface IOperatingSystemOps { - getTempDirectory(): string; -} - -export interface ILocationOps { - combinePaths(...pathSegments: string[]): string; -} - -export interface ICommandOps { - execute(command: string): void; -} - -export interface IFileSystemOps { - setFilePermissions(filePath: string, mode: string | number): Promise; - createDirectory(directoryPath: string, isRecursive?: boolean): Promise; - writeToFile(filePath: string, data: string): Promise; -} diff --git a/src/infrastructure/SystemOperations/WindowInjectedSystemOperations.ts b/src/infrastructure/SystemOperations/WindowInjectedSystemOperations.ts deleted file mode 100644 index b1be9800..00000000 --- a/src/infrastructure/SystemOperations/WindowInjectedSystemOperations.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { WindowVariables } from '../WindowVariables/WindowVariables'; -import { ISystemOperations } from './ISystemOperations'; - -export function getWindowInjectedSystemOperations( - windowVariables: Partial = window, -): ISystemOperations { - if (!windowVariables) { - throw new Error('missing window'); - } - if (!windowVariables.system) { - throw new Error('missing system'); - } - return windowVariables.system; -} diff --git a/src/infrastructure/WindowVariables/WindowVariables.ts b/src/infrastructure/WindowVariables/WindowVariables.ts index 4d96c27c..aa61f9a2 100644 --- a/src/infrastructure/WindowVariables/WindowVariables.ts +++ b/src/infrastructure/WindowVariables/WindowVariables.ts @@ -1,11 +1,11 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; -import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations'; import { Logger } from '@/application/Common/Log/Logger'; +import { CodeRunner } from '@/application/CodeRunner'; /* Primary entry point for platform-specific injections */ export interface WindowVariables { readonly isDesktop: boolean; - readonly system?: ISystemOperations; + readonly codeRunner?: CodeRunner; readonly os?: OperatingSystem; readonly log: Logger; } diff --git a/src/infrastructure/WindowVariables/WindowVariablesValidator.ts b/src/infrastructure/WindowVariables/WindowVariablesValidator.ts index 8d2a98b7..a197fbff 100644 --- a/src/infrastructure/WindowVariables/WindowVariablesValidator.ts +++ b/src/infrastructure/WindowVariables/WindowVariablesValidator.ts @@ -1,12 +1,14 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; -import { PropertyKeys } from '@/TypeHelpers'; +import { + PropertyKeys, isBoolean, isFunction, isNumber, isPlainObject, +} from '@/TypeHelpers'; import { WindowVariables } from './WindowVariables'; /** * Checks for consistency in runtime environment properties injected by Electron preloader. */ export function validateWindowVariables(variables: Partial) { - if (!isObject(variables)) { + if (!isPlainObject(variables)) { throw new Error('window is not an object'); } const errors = [...testEveryProperty(variables)]; @@ -21,7 +23,7 @@ function* testEveryProperty(variables: Partial): Iterable): boolean { if (!variables.isDesktop) { return true; } - return isObject(variables.log); + return isPlainObject(variables.log); } -function testSystem(variables: Partial): boolean { +function testCodeRunner(variables: Partial): boolean { if (!variables.isDesktop) { return true; } - return isObject(variables.system); + return isPlainObject(variables.codeRunner) + && isFunction(variables.codeRunner.runCode); } function testIsDesktop(isDesktop: unknown): boolean { @@ -65,17 +68,3 @@ function testIsDesktop(isDesktop: unknown): boolean { } return isBoolean(isDesktop); } - -function isNumber(variable: unknown): variable is number { - return typeof variable === 'number'; -} - -function isBoolean(variable: unknown): variable is boolean { - return typeof variable === 'boolean'; -} - -function isObject(variable: unknown): variable is object { - return Boolean(variable) // the data type of null is an object - && typeof variable === 'object' - && !Array.isArray(variable); -} diff --git a/src/presentation/bootstrapping/DependencyProvider.ts b/src/presentation/bootstrapping/DependencyProvider.ts index b3c70691..72124c9a 100644 --- a/src/presentation/bootstrapping/DependencyProvider.ts +++ b/src/presentation/bootstrapping/DependencyProvider.ts @@ -13,6 +13,7 @@ import { import { PropertyKeys } from '@/TypeHelpers'; import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState'; import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger'; +import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner'; export function provideDependencies( context: IApplicationContext, @@ -62,6 +63,10 @@ export function provideDependencies( InjectionKeys.useLogger, useLogger, ), + useCodeRunner: (di) => di.provide( + InjectionKeys.useCodeRunner, + useCodeRunner, + ), }; registerAll(Object.values(resolvers), api); } diff --git a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue index a3c579d8..3a481251 100644 --- a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue +++ b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue @@ -11,8 +11,6 @@ import { defineComponent, computed } from 'vue'; import { injectKey } from '@/presentation/injectionSymbols'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { CodeRunner } from '@/infrastructure/CodeRunner'; -import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext'; import IconButton from './IconButton.vue'; export default defineComponent({ @@ -22,11 +20,19 @@ export default defineComponent({ setup() { const { currentState, currentContext } = injectKey((keys) => keys.useCollectionState); const { os, isDesktop } = injectKey((keys) => keys.useRuntimeEnvironment); + const { codeRunner } = injectKey((keys) => keys.useCodeRunner); const canRun = computed(() => getCanRunState(currentState.value.os, isDesktop, os)); async function executeCode() { - await runCode(currentContext); + if (!codeRunner) { throw new Error('missing code runner'); } + if (os === undefined) { throw new Error('unidentified host operating system'); } + await codeRunner.runCode( + currentContext.state.code.current, + currentContext.app.info.name, + currentState.value.collection.scripting.fileExtension, + os, + ); } return { @@ -45,13 +51,4 @@ function getCanRunState( const isRunningOnSelectedOs = selectedOs === hostOs; return isDesktopVersion && isRunningOnSelectedOs; } - -async function runCode(context: IReadOnlyApplicationContext) { - const runner = new CodeRunner(); - await runner.runCode( - /* code: */ context.state.code.current, - /* appName: */ context.app.info.name, - /* fileExtension: */ context.state.collection.scripting.fileExtension, - ); -} diff --git a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts index fe16b6bc..a42bf61d 100644 --- a/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts +++ b/src/presentation/components/Scripts/View/Tree/TreeView/TreeRoot/NodeCollection/TreeInputParser.ts @@ -1,3 +1,4 @@ +import { isArray } from '@/TypeHelpers'; import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData'; import { TreeNode } from '../../Node/TreeNode'; import { TreeNodeManager } from '../../Node/TreeNodeManager'; @@ -5,7 +6,7 @@ import { TreeNodeManager } from '../../Node/TreeNodeManager'; export function parseTreeInput( input: readonly TreeInputNodeData[], ): TreeNode[] { - if (!Array.isArray(input)) { + if (!isArray(input)) { throw new Error('input data must be an array'); } const nodes = input.map((nodeData) => createNode(nodeData)); diff --git a/src/presentation/components/Shared/Hooks/UseCodeRunner.ts b/src/presentation/components/Shared/Hooks/UseCodeRunner.ts new file mode 100644 index 00000000..4c4a6f44 --- /dev/null +++ b/src/presentation/components/Shared/Hooks/UseCodeRunner.ts @@ -0,0 +1,9 @@ +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; + +export function useCodeRunner( + window: WindowVariables = globalThis.window, +) { + return { + codeRunner: window.codeRunner, + }; +} diff --git a/src/presentation/electron/preload/ContextBridging/ApiContextBridge.ts b/src/presentation/electron/preload/ContextBridging/ApiContextBridge.ts new file mode 100644 index 00000000..f3bd78d2 --- /dev/null +++ b/src/presentation/electron/preload/ContextBridging/ApiContextBridge.ts @@ -0,0 +1,17 @@ +import { contextBridge } from 'electron'; +import { bindObjectMethods } from './MethodContextBinder'; +import { provideWindowVariables } from './RendererApiProvider'; + +export function connectApisWithContextBridge( + bridgeConnector: BridgeConnector = contextBridge.exposeInMainWorld, + apiObject: object = provideWindowVariables(), + methodContextBinder: MethodContextBinder = bindObjectMethods, +) { + Object.entries(apiObject).forEach(([key, value]) => { + bridgeConnector(key, methodContextBinder(value)); + }); +} + +export type BridgeConnector = typeof contextBridge.exposeInMainWorld; + +export type MethodContextBinder = typeof bindObjectMethods; diff --git a/src/presentation/electron/preload/ContextBridging/MethodContextBinder.ts b/src/presentation/electron/preload/ContextBridging/MethodContextBinder.ts new file mode 100644 index 00000000..fb530774 --- /dev/null +++ b/src/presentation/electron/preload/ContextBridging/MethodContextBinder.ts @@ -0,0 +1,52 @@ +import { + isArray, isFunction, isNullOrUndefined, isPlainObject, +} from '@/TypeHelpers'; + +/** + * Binds method contexts to their original object instances and recursively processes + * nested objects and arrays. This is particularly useful when exposing objects across + * different contexts in Electron, such as from the main process to the renderer process + * via the `contextBridge`. + * + * In Electron's context isolation environment, methods of objects passed through the + * `contextBridge` lose their original context (`this` binding). This function ensures that + * each method retains its binding to its original object, allowing it to work as intended + * when invoked from the renderer process. + * + * This approach decouples context isolation concerns from class implementations, enabling + * classes to operate normally without needing explicit binding or arrow functions to maintain + * the context. + */ +export function bindObjectMethods(obj: T): T { + if (isNullOrUndefined(obj)) { + return obj; + } + if (isPlainObject(obj)) { + bindMethodsOfObject(obj); + Object.values(obj).forEach((value) => { + if (!isNullOrUndefined(value) && !isFunction(value)) { + bindObjectMethods(value); + } + }); + } else if (isArray(obj)) { + obj.forEach((item) => bindObjectMethods(item)); + } + return obj; +} + +function bindMethodsOfObject(obj: T): T { + const prototype = Object.getPrototypeOf(obj); + if (!prototype) { + return obj; + } + Object.getOwnPropertyNames(prototype).forEach((property) => { + if (!prototype.hasOwnProperty.call(obj, property)) { + return; // Skip properties not directly on the prototype + } + const value = obj[property]; + if (isFunction(value)) { + (obj as object)[property] = value.bind(obj); + } + }); + return obj; +} diff --git a/src/presentation/electron/preload/NodeOsMapper.ts b/src/presentation/electron/preload/ContextBridging/NodeOsMapper.ts similarity index 100% rename from src/presentation/electron/preload/NodeOsMapper.ts rename to src/presentation/electron/preload/ContextBridging/NodeOsMapper.ts diff --git a/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts b/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts new file mode 100644 index 00000000..9f9ffcfb --- /dev/null +++ b/src/presentation/electron/preload/ContextBridging/RendererApiProvider.ts @@ -0,0 +1,27 @@ +import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger'; +import { Logger } from '@/application/Common/Log/Logger'; +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; +import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner'; +import { CodeRunner } from '@/application/CodeRunner'; +import { convertPlatformToOs } from './NodeOsMapper'; +import { createSecureFacade } from './SecureFacadeCreator'; + +export function provideWindowVariables( + createCodeRunner: CodeRunnerFactory = () => new TemporaryFileCodeRunner(), + createLogger: LoggerFactory = () => createElectronLogger(), + convertToOs = convertPlatformToOs, + createApiFacade: ApiFacadeFactory = createSecureFacade, +): WindowVariables { + return { + isDesktop: true, + log: createApiFacade(createLogger(), ['info', 'debug', 'warn', 'error']), + os: convertToOs(process.platform), + codeRunner: createApiFacade(createCodeRunner(), ['runCode']), + }; +} + +export type LoggerFactory = () => Logger; + +export type CodeRunnerFactory = () => CodeRunner; + +export type ApiFacadeFactory = typeof createSecureFacade; diff --git a/src/presentation/electron/preload/ContextBridging/SecureFacadeCreator.ts b/src/presentation/electron/preload/ContextBridging/SecureFacadeCreator.ts new file mode 100644 index 00000000..809e4099 --- /dev/null +++ b/src/presentation/electron/preload/ContextBridging/SecureFacadeCreator.ts @@ -0,0 +1,42 @@ +import { isFunction } from '@/TypeHelpers'; + +/** + * Creates a secure proxy for the specified object, exposing only the public properties + * of its interface. + * + * This approach prevents the full exposure of the object, thereby reducing the risk + * of unintended access or misuse. For instance, creating a facade for a class rather + * than exposing the class itself ensures that private members and dependencies + * (such as file access or internal state) remain encapsulated and inaccessible. + */ +export function createSecureFacade( + originalObject: T, + accessibleMembers: KeyTypeCombinations, +): T { + const facade: Partial = {}; + + accessibleMembers.forEach((key: keyof T) => { + const member = originalObject[key]; + if (isFunction(member)) { + facade[key] = ((...args: unknown[]) => { + return member.apply(originalObject, args); + }) as T[keyof T]; + } else { + facade[key] = member; + } + }); + + return facade as T; +} + +type PrependTuple = H extends unknown ? T extends unknown ? + ((h: H, ...t: T) => void) extends ((...r: infer R) => void) ? R : never : never : never; +type RecursionDepthControl = [ + never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, +]; +type AllKeyCombinations = T extends unknown ? + PrependTuple extends infer X ? { + 0: [], 1: AllKeyCombinations + }[[X] extends [never] ? 0 : 1] : never> : + never; +type KeyTypeCombinations = AllKeyCombinations; diff --git a/src/presentation/electron/preload/WindowVariablesProvider.ts b/src/presentation/electron/preload/WindowVariablesProvider.ts deleted file mode 100644 index 3cc5808b..00000000 --- a/src/presentation/electron/preload/WindowVariablesProvider.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations'; -import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger'; -import { Logger } from '@/application/Common/Log/Logger'; -import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; -import { convertPlatformToOs } from './NodeOsMapper'; - -export function provideWindowVariables( - createSystem = createNodeSystemOperations, - createLogger: () => Logger = () => createElectronLogger(), - convertToOs = convertPlatformToOs, -): WindowVariables { - return { - system: createSystem(), - isDesktop: true, - log: createLogger(), - os: convertToOs(process.platform), - }; -} diff --git a/src/presentation/electron/preload/index.ts b/src/presentation/electron/preload/index.ts index 629f7d5d..f4aeb82f 100644 --- a/src/presentation/electron/preload/index.ts +++ b/src/presentation/electron/preload/index.ts @@ -1,9 +1,8 @@ // This file is used to securely expose Electron APIs to the application. -import { contextBridge } from 'electron'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; -import { provideWindowVariables } from './WindowVariablesProvider'; +import { connectApisWithContextBridge } from './ContextBridging/ApiContextBridge'; validateRuntimeSanity({ // Validate metadata as a preventive measure for fail-fast, @@ -14,10 +13,7 @@ validateRuntimeSanity({ validateWindowVariables: false, }); -const windowVariables = provideWindowVariables(); -Object.entries(windowVariables).forEach(([key, value]) => { - contextBridge.exposeInMainWorld(key, value); -}); +connectApisWithContextBridge(); // Do not remove [PRELOAD_INIT]; it's a marker used in tests. ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.'); diff --git a/src/presentation/injectionSymbols.ts b/src/presentation/injectionSymbols.ts index 0e5fcf60..3d8cf46e 100644 --- a/src/presentation/injectionSymbols.ts +++ b/src/presentation/injectionSymbols.ts @@ -7,6 +7,7 @@ import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseC 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 { useCodeRunner } from './components/Shared/Hooks/UseCodeRunner'; export const InjectionKeys = { useCollectionState: defineTransientKey>('useCollectionState'), @@ -17,6 +18,7 @@ export const InjectionKeys = { useCurrentCode: defineTransientKey>('useCurrentCode'), useUserSelectionState: defineTransientKey>('useUserSelectionState'), useLogger: defineTransientKey>('useLogger'), + useCodeRunner: defineTransientKey>('useCodeRunner'), }; export interface InjectionKeyWithLifetime { diff --git a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/text.ts b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/text.ts index 8176761b..9a6acf2b 100644 --- a/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/text.ts +++ b/tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/utils/text.ts @@ -1,3 +1,5 @@ +import { isString } from '@/TypeHelpers'; + export function indentText( text: string, indentLevel = 1, @@ -21,7 +23,7 @@ export function filterEmpty(texts: readonly (string | undefined | null)[]): stri } function validateText(text: string): void { - if (typeof text !== 'string') { + if (!isString(text)) { throw new Error(`text is not a string. It is: ${typeof text}\n${text}`); } } diff --git a/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts b/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts index b10f7a69..b4fcd0ad 100644 --- a/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts +++ b/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts @@ -1,6 +1,7 @@ import { describe } from 'vitest'; import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; +import { isBoolean } from '@/TypeHelpers'; describe('SanityChecks', () => { describe('validateRuntimeSanity', () => { @@ -42,7 +43,7 @@ function generateBooleanPermutations(object: T | undefined): T[] { const currentKey = keys[0]; const currentValue = object[currentKey]; - if (typeof currentValue !== 'boolean') { + if (!isBoolean(currentValue)) { return generateBooleanPermutations({ ...object, [currentKey]: currentValue, diff --git a/tests/integration/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts b/tests/integration/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts new file mode 100644 index 00000000..679aa7eb --- /dev/null +++ b/tests/integration/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts @@ -0,0 +1,15 @@ +import { it, describe, expect } from 'vitest'; +import { connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge'; + +describe('ApiContextBridge', () => { + describe('connectApisWithContextBridge', () => { + it('can provide keys and values', () => { + // arrange + const bridgeConnector = () => {}; + // act + const act = () => connectApisWithContextBridge(bridgeConnector); + // assert + expect(act).to.not.throw(); + }); + }); +}); diff --git a/tests/integration/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts b/tests/integration/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts new file mode 100644 index 00000000..29364b01 --- /dev/null +++ b/tests/integration/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts @@ -0,0 +1,66 @@ +import { it, describe, expect } from 'vitest'; +import { provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider'; +import { + isArray, isBoolean, isFunction, isNullOrUndefined, isNumber, isPlainObject, isString, +} from '@/TypeHelpers'; + +describe('RendererApiProvider', () => { + describe('provideWindowVariables', () => { + describe('conforms to Electron\'s context bridging requirements', () => { + // https://www.electronjs.org/docs/latest/api/context-bridge + const variables = provideWindowVariables(); + Object.entries(variables).forEach(([key, value]) => { + it(`\`${key}\` conforms to allowed types for context bridging`, () => { + // act + const act = () => checkAllowedType(value); + // assert + expect(act).to.not.throw(); + }); + }); + }); + }); +}); + +function checkAllowedType(value: unknown): void { + if (isBasicType(value)) { + return; + } + if (isArray(value)) { + checkArrayElements(value); + return; + } + if (!isPlainObject(value)) { + throw new Error(`Type error: Expected a valid object, array, or primitive type, but received type '${typeof value}'.`); + } + if (isNullOrUndefined(value)) { + throw new Error('Type error: Value is null or undefined, which is not allowed.'); + } + checkObjectProperties(value); +} + +function isBasicType(value: unknown): boolean { + return isString(value) || isNumber(value) || isBoolean(value) || isFunction(value); +} + +function checkArrayElements(array: unknown[]): void { + array.forEach((item, index) => { + try { + checkAllowedType(item); + } catch (error) { + throw new Error(`Invalid array element at index ${index}: ${error.message}`); + } + }); +} + +function checkObjectProperties(obj: NonNullable): void { + if (Object.keys(obj).some((key) => !isString(key))) { + throw new Error('Type error: At least one object key is not a string, which violates the allowed types.'); + } + Object.entries(obj).forEach(([key, memberValue]) => { + try { + checkAllowedType(memberValue); + } catch (error) { + throw new Error(`Invalid object property '${key}': ${error.message}`); + } + }); +} diff --git a/tests/integration/presentation/electron/preload/WindowVariablesProvider.spec.ts b/tests/integration/presentation/electron/preload/WindowVariablesProvider.spec.ts deleted file mode 100644 index ecbd7873..00000000 --- a/tests/integration/presentation/electron/preload/WindowVariablesProvider.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { it, describe, expect } from 'vitest'; -import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider'; - -describe('WindowVariablesProvider', () => { - describe('provideWindowVariables', () => { - describe('conforms to Electron\'s context bridging requirements', () => { - // https://www.electronjs.org/docs/latest/api/context-bridge - const variables = provideWindowVariables(); - Object.entries(variables).forEach(([key, value]) => { - it(`\`${key}\` conforms to allowed types for context bridging`, () => { - expect(checkAllowedType(value)).to.equal(true); - }); - }); - }); - }); -}); - -function checkAllowedType(value: unknown) { - const type = typeof value; - if (['string', 'number', 'boolean', 'function'].includes(type)) { - return true; - } - if (Array.isArray(value)) { - return value.every(checkAllowedType); - } - if (type === 'object' && value !== null && value !== undefined) { - return ( - // Every key should be a string - Object.keys(value).every((key) => typeof key === 'string') - // Every value should be of allowed type - && Object.values(value).every(checkAllowedType) - ); - } - return false; -} diff --git a/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts b/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts index 36abebe5..74ee912b 100644 --- a/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts +++ b/tests/unit/application/Context/State/Code/Generation/CodeBuilder.spec.ts @@ -91,8 +91,8 @@ describe('CodeBuilder', () => { it('appendFunction', () => { // arrange const sut = new CodeBuilderConcrete(); - const functionName = 'function'; - const code = 'code'; + const functionName = 'expected-function-name'; + const code = 'expected-code'; // act sut.appendFunction(functionName, code); // assert diff --git a/tests/unit/infrastructure/CodeRunner.spec.ts b/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts similarity index 87% rename from tests/unit/infrastructure/CodeRunner.spec.ts rename to tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts index 9a97f5d8..274bc005 100644 --- a/tests/unit/infrastructure/CodeRunner.spec.ts +++ b/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts @@ -1,17 +1,16 @@ import { describe, it, expect } from 'vitest'; -import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { CodeRunner } from '@/infrastructure/CodeRunner'; +import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; +import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner'; import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub'; import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub'; import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub'; import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub'; -import { IFileSystemOps, ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations'; import { FunctionKeys } from '@/TypeHelpers'; -describe('CodeRunner', () => { +describe('TemporaryFileCodeRunner', () => { describe('runCode', () => { it('creates temporary directory recursively', async () => { // arrange @@ -121,11 +120,11 @@ describe('CodeRunner', () => { describe('executes as expected', () => { // arrange const filePath = 'expected-file-path'; - interface IExecutionTestCase { + interface ExecutionTestCase { readonly givenOs: OperatingSystem; readonly expectedCommand: string; } - const testData: readonly IExecutionTestCase[] = [ + const testData: readonly ExecutionTestCase[] = [ { givenOs: OperatingSystem.Windows, expectedCommand: filePath, @@ -164,7 +163,7 @@ describe('CodeRunner', () => { } }); it('runs in expected order', async () => { // verifies correct `async`, `await` usage. - const expectedOrder: readonly FunctionKeys[] = [ + const expectedOrder: readonly FunctionKeys[] = [ 'createDirectory', 'writeToFile', 'setFilePermissions', @@ -186,7 +185,7 @@ describe('CodeRunner', () => { describe('throws with invalid OS', () => { const testScenarios: ReadonlyArray<{ readonly description: string; - readonly invalidOs: OperatingSystem | undefined; + readonly invalidOs: OperatingSystem; readonly expectedError: string; }> = [ (() => { @@ -197,11 +196,6 @@ describe('CodeRunner', () => { expectedError: `unsupported os: ${OperatingSystem[unsupportedOs]}`, }; })(), - { - description: 'unknown OS', - invalidOs: undefined, - expectedError: 'Unidentified operating system', - }, ]; testScenarios.forEach(({ description, invalidOs, expectedError }) => { it(description, async () => { @@ -225,19 +219,17 @@ class TestContext { private fileExtension = 'fileExtension'; - private os: OperatingSystem | undefined = OperatingSystem.Windows; + private os: OperatingSystem = OperatingSystem.Windows; - private systemOperations: ISystemOperations = new SystemOperationsStub(); + private systemOperations: SystemOperations = new SystemOperationsStub(); public async runCode(): Promise { - const environment = new RuntimeEnvironmentStub() - .withOs(this.os); - const runner = new CodeRunner(this.systemOperations, environment); - await runner.runCode(this.code, this.folderName, this.fileExtension); + const runner = new TemporaryFileCodeRunner(this.systemOperations); + await runner.runCode(this.code, this.folderName, this.fileExtension, this.os); } public withSystemOperations( - systemOperations: ISystemOperations, + systemOperations: SystemOperations, ): this { this.systemOperations = systemOperations; return this; @@ -250,22 +242,22 @@ class TestContext { return this.withSystemOperations(stub); } - public withOs(os: OperatingSystem | undefined) { + public withOs(os: OperatingSystem): this { this.os = os; return this; } - public withFolderName(folderName: string) { + public withFolderName(folderName: string): this { this.folderName = folderName; return this; } - public withCode(code: string) { + public withCode(code: string): this { this.code = code; return this; } - public withExtension(fileExtension: string) { + public withExtension(fileExtension: string): this { this.fileExtension = fileExtension; return this; } diff --git a/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts index 70af3f0d..7860d2af 100644 --- a/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts +++ b/tests/unit/infrastructure/RuntimeEnvironment/WindowVariablesValidator.spec.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest'; import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; -import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +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'; describe('WindowVariablesValidator', () => { describe('validateWindowVariables', () => { @@ -92,51 +92,35 @@ describe('WindowVariablesValidator', () => { }); describe('`isDesktop` property', () => { - it('throws an error when only isDesktop is provided and it is true without a system object', () => { + it('does not throw when true with valid services', () => { // arrange - const systemObject = undefined; - const expectedError = getExpectedError( - { - name: 'system', - object: systemObject, - }, - ); + const validCodeRunner = new CodeRunnerStub(); const input = new WindowVariablesStub() .withIsDesktop(true) - .withSystem(systemObject); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.throw(expectedError); - }); - - it('does not throw when isDesktop is true with a valid system object', () => { - // arrange - const validSystem = new SystemOperationsStub(); - const input = new WindowVariablesStub() - .withIsDesktop(true) - .withSystem(validSystem); + .withCodeRunner(validCodeRunner); // act const act = () => validateWindowVariables(input); // assert expect(act).to.not.throw(); }); - it('does not throw when isDesktop is false without a system object', () => { - // arrange - const absentSystem = undefined; - const input = new WindowVariablesStub() - .withIsDesktop(false) - .withSystem(absentSystem); - // act - const act = () => validateWindowVariables(input); - // assert - expect(act).to.not.throw(); + describe('does not throw when false without services', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const absentCodeRunner = absentValue; + const input = new WindowVariablesStub() + .withIsDesktop(false) + .withCodeRunner(absentCodeRunner); + // act + const act = () => validateWindowVariables(input); + // assert + expect(act).to.not.throw(); + }, { excludeNull: true }); }); }); - describe('`system` property', () => { - expectObjectOnDesktop('system'); + describe('`codeRunner` property', () => { + expectObjectOnDesktop('codeRunner'); }); describe('`log` property', () => { @@ -158,6 +142,7 @@ function expectObjectOnDesktop(key: keyof WindowVariables) { describe('validates object type on desktop', () => { itEachInvalidObjectValue((invalidObjectValue) => { // arrange + const isOnDesktop = true; const invalidObject = invalidObjectValue as T; const expectedError = getExpectedError({ name: key, @@ -165,7 +150,7 @@ function expectObjectOnDesktop(key: keyof WindowVariables) { }); const input: WindowVariables = { ...new WindowVariablesStub(), - isDesktop: true, + isDesktop: isOnDesktop, [key]: invalidObject, }; // act diff --git a/tests/unit/infrastructure/SystemOperations/WindowInjectedSystemOperations.spec.ts b/tests/unit/infrastructure/SystemOperations/WindowInjectedSystemOperations.spec.ts deleted file mode 100644 index d1dd8095..00000000 --- a/tests/unit/infrastructure/SystemOperations/WindowInjectedSystemOperations.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getWindowInjectedSystemOperations } from '@/infrastructure/SystemOperations/WindowInjectedSystemOperations'; -import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; -import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; - -describe('WindowInjectedSystemOperations', () => { - describe('getWindowInjectedSystemOperations', () => { - describe('throws if window is absent', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const expectedError = 'missing window'; - const window: WindowVariables = absentValue as never; - // act - const act = () => getWindowInjectedSystemOperations(window); - // assert - expect(act).to.throw(expectedError); - }, { excludeUndefined: true }); - }); - describe('throw if system is absent', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const expectedError = 'missing system'; - const absentSystem = absentValue; - const window: Partial = { - system: absentSystem as never, - }; - // act - const act = () => getWindowInjectedSystemOperations(window); - // assert - expect(act).to.throw(expectedError); - }); - }); - it('returns from window', () => { - // arrange - const expectedValue = new SystemOperationsStub(); - const window: Partial = { - system: expectedValue, - }; - // act - const actualValue = getWindowInjectedSystemOperations(window); - // assert - expect(actualValue).to.equal(expectedValue); - }); - }); -}); diff --git a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts index e64a5b21..45ea54f0 100644 --- a/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts +++ b/tests/unit/presentation/bootstrapping/DependencyProvider.spec.ts @@ -18,6 +18,7 @@ describe('DependencyProvider', () => { useCurrentCode: createTransientTests(), useUserSelectionState: createTransientTests(), useLogger: createTransientTests(), + useCodeRunner: createTransientTests(), }; Object.entries(testCases).forEach(([key, runTests]) => { const registeredKey = InjectionKeys[key].key; diff --git a/tests/unit/presentation/components/Shared/Hooks/UseCodeRunner.spec.ts b/tests/unit/presentation/components/Shared/Hooks/UseCodeRunner.spec.ts new file mode 100644 index 00000000..97af6e84 --- /dev/null +++ b/tests/unit/presentation/components/Shared/Hooks/UseCodeRunner.spec.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { useCodeRunner } from '@/presentation/components/Shared/Hooks/UseCodeRunner'; + +describe('UseCodeRunner', () => { + it('returns from the provided window object', () => { + // arrange + const mockCodeRunner = { run: () => {} }; + const mockWindow = { codeRunner: mockCodeRunner } as unknown as Window; + + // act + const { codeRunner } = useCodeRunner(mockWindow); + + // assert + expect(codeRunner).to.equal(mockCodeRunner); + }); + + it('returns undefined when not defined in the window object', () => { + // Arrange + const mockWindow = {} as unknown as Window; + + // Act + const { codeRunner } = useCodeRunner(mockWindow); + + // Assert + expect(codeRunner).toBeUndefined(); + }); +}); diff --git a/tests/unit/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts new file mode 100644 index 00000000..30df5747 --- /dev/null +++ b/tests/unit/presentation/electron/preload/ContextBridging/ApiContextBridge.spec.ts @@ -0,0 +1,96 @@ +import { it, describe, expect } from 'vitest'; +import { BridgeConnector, MethodContextBinder, connectApisWithContextBridge } from '@/presentation/electron/preload/ContextBridging/ApiContextBridge'; + +describe('ApiContextBridge', () => { + describe('connectApisWithContextBridge', () => { + it('connects properties as keys', () => { + // arrange + const context = new BridgeConnectorTestContext(); + const { exposedItems, bridgeConnector } = mockBridgeConnector(); + const expectedKeys = ['a', 'b']; + const api = { + [`${expectedKeys[0]}`]: () => {}, + [`${expectedKeys[1]}`]: () => {}, + }; + + // act + context + .withApiObject(api) + .withBridgeConnector(bridgeConnector) + .run(); + + // assert + expect(exposedItems).to.have.lengthOf(expectedKeys.length); + expect(exposedItems.map(([key]) => key)).to.have.members(expectedKeys); + }); + it('connects values after binding their context', () => { + // arrange + const context = new BridgeConnectorTestContext(); + const { exposedItems, bridgeConnector } = mockBridgeConnector(); + const rawValues = ['a', 'b']; + const api = { + first: rawValues[0], + second: rawValues[1], + }; + const boundValues = { + [`${rawValues[0]}`]: 'bound-a', + [`${rawValues[1]}`]: 'bound-b', + }; + const expectedValues = Object.values(boundValues); + const contextBinderMock: MethodContextBinder = (property) => { + return boundValues[property as string] as never; + }; + + // act + context + .withApiObject(api) + .withContextBinder(contextBinderMock) + .withBridgeConnector(bridgeConnector) + .run(); + + // assert + expect(exposedItems).to.have.lengthOf(rawValues.length); + expect(exposedItems.map(([,value]) => value)).to.have.members(expectedValues); + }); + }); +}); + +function mockBridgeConnector() { + const exposedItems = new Array<[string, unknown]>(); + const bridgeConnector: BridgeConnector = (key, api) => exposedItems.push([key, api]); + return { + exposedItems, + bridgeConnector, + }; +} + +class BridgeConnectorTestContext { + private bridgeConnector: BridgeConnector = () => {}; + + private apiObject: object = {}; + + private contextBinder: MethodContextBinder = (obj) => obj; + + public withBridgeConnector(bridgeConnector: BridgeConnector): this { + this.bridgeConnector = bridgeConnector; + return this; + } + + public withApiObject(apiObject: object): this { + this.apiObject = apiObject; + return this; + } + + public withContextBinder(contextBinder: MethodContextBinder): this { + this.contextBinder = contextBinder; + return this; + } + + public run() { + return connectApisWithContextBridge( + this.bridgeConnector, + this.apiObject, + this.contextBinder, + ); + } +} diff --git a/tests/unit/presentation/electron/preload/ContextBridging/MethodContextBinder.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/MethodContextBinder.spec.ts new file mode 100644 index 00000000..5c665e4e --- /dev/null +++ b/tests/unit/presentation/electron/preload/ContextBridging/MethodContextBinder.spec.ts @@ -0,0 +1,132 @@ +/* eslint-disable max-classes-per-file */ +import { describe, it, expect } from 'vitest'; +import { bindObjectMethods } from '@/presentation/electron/preload/ContextBridging/MethodContextBinder'; + +describe('MethodContextBinder', () => { + describe('bindObjectMethods', () => { + it('binds methods of an object to itself', () => { + // arrange + class TestClass { + constructor(public value: number) {} + + increment() { + this.value += 1; + } + } + const instance = new TestClass(0); + + // act + const boundInstance = bindObjectMethods(instance); + boundInstance.increment(); + + // assert + expect(boundInstance.value).toBe(1); + }); + + it('handles objects without prototype methods gracefully', () => { + // arrange + const object = Object.create(null); // object without prototype + object.value = 0; + // eslint-disable-next-line func-names + object.increment = function () { + this.value += 1; + }; + + // act + const boundObject = bindObjectMethods(object); + + // assert + expect(() => boundObject.increment()).not.toThrow(); + }); + + it('recursively binds methods in nested objects', () => { + // arrange + const nestedObject = { + child: { + value: 0, + increment() { + this.value += 1; + }, + }, + }; + + // act + const boundObject = bindObjectMethods(nestedObject); + boundObject.child.increment(); + + // assert + expect(boundObject.child.value).toBe(1); + }); + + it('recursively binds methods in arrays', () => { + // arrange + const array = [ + { + value: 0, + increment() { + this.value += 1; + }, + }, + ]; + + // act + const boundArray = bindObjectMethods(array); + boundArray[0].increment(); + + // assert + expect(boundArray[0].value).toBe(1); + }); + + describe('returns the same object if it is neither an object nor an array', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly value: unknown; + }> = [ + { + description: 'given primitive', + value: 42, + }, + { + description: 'null', + value: null, + }, + { + description: 'undefined', + value: undefined, + }, + ]; + testScenarios.forEach(({ description, value }) => { + it(description, () => { + // act + const boundValue = bindObjectMethods(value); + // assert + expect(boundValue).toBe(value); + }); + }); + }); + + it('skips binding inherited properties', () => { + // arrange + class ParentClass { + inheritedMethod() {} + } + class TestClass extends ParentClass { + constructor(public value: number) { + super(); + } + + increment() { + this.value += 1; + } + } + const instance = new TestClass(0); + const originalInheritedMethod = instance.inheritedMethod; + + // act + const boundInstance = bindObjectMethods(instance); + + // assert + expect(boundInstance.inheritedMethod).toBe(originalInheritedMethod); + }); + }); +}); diff --git a/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/NodeOsMapper.spec.ts similarity index 98% rename from tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts rename to tests/unit/presentation/electron/preload/ContextBridging/NodeOsMapper.spec.ts index d1686443..19c2f3b6 100644 --- a/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts +++ b/tests/unit/presentation/electron/preload/ContextBridging/NodeOsMapper.spec.ts @@ -1,6 +1,6 @@ import { describe } from 'vitest'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper'; +import { convertPlatformToOs } from '@/presentation/electron/preload/ContextBridging/NodeOsMapper'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; describe('NodeOsMapper', () => { diff --git a/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts new file mode 100644 index 00000000..2d9d0597 --- /dev/null +++ b/tests/unit/presentation/electron/preload/ContextBridging/RendererApiProvider.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { ApiFacadeFactory, provideWindowVariables } from '@/presentation/electron/preload/ContextBridging/RendererApiProvider'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { Logger } from '@/application/Common/Log/Logger'; +import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; +import { CodeRunner } from '@/application/CodeRunner'; +import { CodeRunnerStub } from '@tests/unit/shared/Stubs/CodeRunnerStub'; +import { PropertyKeys } from '@/TypeHelpers'; +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; + +describe('RendererApiProvider', () => { + describe('provideWindowVariables', () => { + interface WindowVariableTestCase { + readonly description: string; + setupContext(context: RendererApiProviderTestContext): RendererApiProviderTestContext; + readonly expectedValue: unknown; + } + const testScenarios: Record>, WindowVariableTestCase> = { + isDesktop: { + description: 'returns true', + setupContext: (context) => context, + expectedValue: true, + }, + codeRunner: (() => { + const codeRunner = new CodeRunnerStub(); + const createFacadeMock: ApiFacadeFactory = (obj) => obj; + return { + description: 'encapsulates correctly', + setupContext: (context) => context + .withCodeRunner(codeRunner) + .withApiFacadeCreator(createFacadeMock), + expectedValue: codeRunner, + }; + })(), + os: (() => { + const operatingSystem = OperatingSystem.WindowsPhone; + return { + description: 'returns expected', + setupContext: (context) => context.withOs(operatingSystem), + expectedValue: operatingSystem, + }; + })(), + log: (() => { + const logger = new LoggerStub(); + const createFacadeMock: ApiFacadeFactory = (obj) => obj; + return { + description: 'encapsulates correctly', + setupContext: (context) => context + .withLogger(logger) + .withApiFacadeCreator(createFacadeMock), + expectedValue: logger, + }; + })(), + }; + Object.entries(testScenarios).forEach(( + [property, { description, setupContext, expectedValue }], + ) => { + it(`${property}: ${description}`, () => { + // arrange + const testContext = setupContext(new RendererApiProviderTestContext()); + // act + const variables = testContext.provideWindowVariables(); + // assert + const actualValue = variables[property]; + expect(actualValue).to.equal(expectedValue); + }); + }); + }); +}); + +class RendererApiProviderTestContext { + private codeRunner: CodeRunner = new CodeRunnerStub(); + + private os: OperatingSystem = OperatingSystem.Android; + + private log: Logger = new LoggerStub(); + + private apiFacadeCreator: ApiFacadeFactory = (obj) => obj; + + public withCodeRunner(codeRunner: CodeRunner): this { + this.codeRunner = codeRunner; + return this; + } + + public withOs(os: OperatingSystem): this { + this.os = os; + return this; + } + + public withLogger(log: Logger): this { + this.log = log; + return this; + } + + public withApiFacadeCreator(apiFacadeCreator: ApiFacadeFactory): this { + this.apiFacadeCreator = apiFacadeCreator; + return this; + } + + public provideWindowVariables() { + return provideWindowVariables( + () => this.codeRunner, + () => this.log, + () => this.os, + this.apiFacadeCreator, + ); + } +} diff --git a/tests/unit/presentation/electron/preload/ContextBridging/SecureFacadeCreator.spec.ts b/tests/unit/presentation/electron/preload/ContextBridging/SecureFacadeCreator.spec.ts new file mode 100644 index 00000000..09ffbd4b --- /dev/null +++ b/tests/unit/presentation/electron/preload/ContextBridging/SecureFacadeCreator.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { createSecureFacade } from '@/presentation/electron/preload/ContextBridging/SecureFacadeCreator'; + +describe('SecureFacadeCreator', () => { + describe('createSecureFacade', () => { + describe('methods', () => { + it('allows access to external methods', () => { + // arrange + let value = 0; + const testObject = { + increment: () => value++, + }; + const facade = createSecureFacade(testObject, ['increment']); + + // act + facade.increment(); + + // assert + expect(value).toBe(1); + }); + it('proxies external methods', () => { + // arrange + const testObject = { + method: () => {}, + }; + const facade = createSecureFacade(testObject, ['method']); + + // act + const actualMethod = facade.method; + + // assert + expect(testObject.method).not.equal(actualMethod); + expect(testObject.method).not.equal(actualMethod); + }); + it('does not expose internal methods', () => { + // arrange + interface External { + publicMethod(): void; + } + interface Internal { + privateMethod(): void; + } + const testObject: External & Internal = { + publicMethod: () => {}, + privateMethod: () => {}, + }; + const facade = createSecureFacade(testObject, ['publicMethod']); + + // act + facade.publicMethod(); + + // assert + expect((facade as unknown as Internal).privateMethod).toBeUndefined(); + }); + it('maintains original function context', () => { + // arrange + const testObject = { + value: 0, + increment() { this.value++; }, + }; + // act + const facade = createSecureFacade(testObject, ['increment', 'value']); + // assert + facade.increment(); + expect(testObject.value).toBe(1); + }); + }); + describe('properties', () => { + it('allows access to external properties', () => { + // arrange + const testObject = { a: 1 }; + // act + const facade = createSecureFacade(testObject, ['a']); + // assert + expect(facade.a).toBe(1); + }); + it('does not expose internal properties', () => { + // arrange + interface External { + readonly public: string; + } + interface Internal { + readonly private: string; + } + const testObject: External & Internal = { + public: '', + private: '', + }; + const facade = createSecureFacade(testObject, ['public']); + + // act + (() => facade.public)(); + + // assert + expect((facade as unknown as Internal).private).toBeUndefined(); + }); + }); + }); +}); diff --git a/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts b/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts deleted file mode 100644 index 82f86a2c..00000000 --- a/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider'; -import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations'; -import { Logger } from '@/application/Common/Log/Logger'; -import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; - -describe('WindowVariablesProvider', () => { - describe('provideWindowVariables', () => { - it('returns expected `system`', () => { - // arrange - const expectedValue = new SystemOperationsStub(); - // act - const variables = new TestContext() - .withSystem(expectedValue) - .provideWindowVariables(); - // assert - expect(variables.system).to.equal(expectedValue); - }); - it('returns expected `os`', () => { - // arrange - const expectedValue = OperatingSystem.WindowsPhone; - // act - const variables = new TestContext() - .withOs(expectedValue) - .provideWindowVariables(); - // assert - expect(variables.os).to.equal(expectedValue); - }); - it('returns expected `log`', () => { - // arrange - const expectedValue = new LoggerStub(); - // act - const variables = new TestContext() - .withLogger(expectedValue) - .provideWindowVariables(); - // assert - expect(variables.log).to.equal(expectedValue); - }); - it('`isDesktop` is true', () => { - // arrange - const expectedValue = true; - // act - const variables = new TestContext() - .provideWindowVariables(); - // assert - expect(variables.isDesktop).to.equal(expectedValue); - }); - }); -}); - -class TestContext { - private system: ISystemOperations = new SystemOperationsStub(); - - private os: OperatingSystem = OperatingSystem.Android; - - private log: Logger = new LoggerStub(); - - public withSystem(system: ISystemOperations): this { - this.system = system; - return this; - } - - public withOs(os: OperatingSystem): this { - this.os = os; - return this; - } - - public withLogger(log: Logger): this { - this.log = log; - return this; - } - - public provideWindowVariables() { - return provideWindowVariables( - () => this.system, - () => this.log, - () => this.os, - ); - } -} diff --git a/tests/unit/shared/Stubs/CodeRunnerStub.ts b/tests/unit/shared/Stubs/CodeRunnerStub.ts new file mode 100644 index 00000000..cef0ceae --- /dev/null +++ b/tests/unit/shared/Stubs/CodeRunnerStub.ts @@ -0,0 +1,7 @@ +import { CodeRunner } from '@/application/CodeRunner'; + +export class CodeRunnerStub implements CodeRunner { + public runCode(): Promise { + return Promise.resolve(); + } +} diff --git a/tests/unit/shared/Stubs/CommandOpsStub.ts b/tests/unit/shared/Stubs/CommandOpsStub.ts index c2a3d74c..1059b9ca 100644 --- a/tests/unit/shared/Stubs/CommandOpsStub.ts +++ b/tests/unit/shared/Stubs/CommandOpsStub.ts @@ -1,13 +1,14 @@ -import { ICommandOps } from '@/infrastructure/SystemOperations/ISystemOperations'; +import { CommandOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; export class CommandOpsStub - extends StubWithObservableMethodCalls - implements ICommandOps { - public execute(command: string): void { + extends StubWithObservableMethodCalls + implements CommandOps { + public execute(command: string): Promise { this.registerMethodCall({ methodName: 'execute', args: [command], }); + return Promise.resolve(); } } diff --git a/tests/unit/shared/Stubs/FileSystemOpsStub.ts b/tests/unit/shared/Stubs/FileSystemOpsStub.ts index dccffa16..fc37ab5c 100644 --- a/tests/unit/shared/Stubs/FileSystemOpsStub.ts +++ b/tests/unit/shared/Stubs/FileSystemOpsStub.ts @@ -1,9 +1,9 @@ -import { IFileSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations'; +import { FileSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; export class FileSystemOpsStub - extends StubWithObservableMethodCalls - implements IFileSystemOps { + extends StubWithObservableMethodCalls + implements FileSystemOps { public setFilePermissions(filePath: string, mode: string | number): Promise { this.registerMethodCall({ methodName: 'setFilePermissions', diff --git a/tests/unit/shared/Stubs/LocationOpsStub.ts b/tests/unit/shared/Stubs/LocationOpsStub.ts index 0570879e..5c8b75bf 100644 --- a/tests/unit/shared/Stubs/LocationOpsStub.ts +++ b/tests/unit/shared/Stubs/LocationOpsStub.ts @@ -1,9 +1,9 @@ -import { ILocationOps } from '@/infrastructure/SystemOperations/ISystemOperations'; +import { LocationOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; export class LocationOpsStub - extends StubWithObservableMethodCalls - implements ILocationOps { + extends StubWithObservableMethodCalls + implements LocationOps { private sequence = new Array(); private scenarios = new Map(); diff --git a/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts b/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts index 418cc0a6..f7f8e3c6 100644 --- a/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts +++ b/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts @@ -1,9 +1,9 @@ -import { IOperatingSystemOps } from '@/infrastructure/SystemOperations/ISystemOperations'; +import { OperatingSystemOps } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; export class OperatingSystemOpsStub - extends StubWithObservableMethodCalls - implements IOperatingSystemOps { + extends StubWithObservableMethodCalls + implements OperatingSystemOps { private temporaryDirectory = '/stub-temp-dir/'; public withTemporaryDirectoryResult(directory: string): this { diff --git a/tests/unit/shared/Stubs/SystemOperationsStub.ts b/tests/unit/shared/Stubs/SystemOperationsStub.ts index fa83e5be..2b9ab3bc 100644 --- a/tests/unit/shared/Stubs/SystemOperationsStub.ts +++ b/tests/unit/shared/Stubs/SystemOperationsStub.ts @@ -1,40 +1,40 @@ -import { - ICommandOps, - IFileSystemOps, - IOperatingSystemOps, - ILocationOps, - ISystemOperations, -} from '@/infrastructure/SystemOperations/ISystemOperations'; +import type { + CommandOps, + FileSystemOps, + OperatingSystemOps, + LocationOps, + SystemOperations, +} from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; import { CommandOpsStub } from './CommandOpsStub'; import { FileSystemOpsStub } from './FileSystemOpsStub'; import { LocationOpsStub } from './LocationOpsStub'; import { OperatingSystemOpsStub } from './OperatingSystemOpsStub'; -export class SystemOperationsStub implements ISystemOperations { - public operatingSystem: IOperatingSystemOps = new OperatingSystemOpsStub(); +export class SystemOperationsStub implements SystemOperations { + public operatingSystem: OperatingSystemOps = new OperatingSystemOpsStub(); - public location: ILocationOps = new LocationOpsStub(); + public location: LocationOps = new LocationOpsStub(); - public fileSystem: IFileSystemOps = new FileSystemOpsStub(); + public fileSystem: FileSystemOps = new FileSystemOpsStub(); - public command: ICommandOps = new CommandOpsStub(); + public command: CommandOps = new CommandOpsStub(); - public withOperatingSystem(operatingSystemOps: IOperatingSystemOps): this { + public withOperatingSystem(operatingSystemOps: OperatingSystemOps): this { this.operatingSystem = operatingSystemOps; return this; } - public withLocation(location: ILocationOps): this { + public withLocation(location: LocationOps): this { this.location = location; return this; } - public withFileSystem(fileSystem: IFileSystemOps): this { + public withFileSystem(fileSystem: FileSystemOps): this { this.fileSystem = fileSystem; return this; } - public withCommand(command: ICommandOps): this { + public withCommand(command: CommandOps): this { this.command = command; return this; } diff --git a/tests/unit/shared/Stubs/WindowVariablesStub.ts b/tests/unit/shared/Stubs/WindowVariablesStub.ts index 78c543ac..88816beb 100644 --- a/tests/unit/shared/Stubs/WindowVariablesStub.ts +++ b/tests/unit/shared/Stubs/WindowVariablesStub.ts @@ -1,12 +1,12 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { Logger } from '@/application/Common/Log/Logger'; -import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations'; import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; -import { SystemOperationsStub } from './SystemOperationsStub'; +import { CodeRunner } from '@/application/CodeRunner'; import { LoggerStub } from './LoggerStub'; +import { CodeRunnerStub } from './CodeRunnerStub'; export class WindowVariablesStub implements WindowVariables { - public system?: ISystemOperations = new SystemOperationsStub(); + public codeRunner?: CodeRunner = new CodeRunnerStub(); public isDesktop = false; @@ -29,8 +29,8 @@ export class WindowVariablesStub implements WindowVariables { return this; } - public withSystem(value?: ISystemOperations): this { - this.system = value; + public withCodeRunner(value?: CodeRunner): this { + this.codeRunner = value; return this; } }