From 40f5eb8334b27e958eee63e2141ded7d5861d960 Mon Sep 17 00:00:00 2001 From: undergroundwires Date: Tue, 2 Jan 2024 16:16:31 +0100 Subject: [PATCH] Fix handling special chars in script paths This commit improves the handling of paths with spaces or special characters during script execution in the desktop application. Key improvements: - Paths are now quoted for macOS/Linux, addressing issues with whitespace or single quotes. - Windows paths are enclosed in double quotes to handle special characters. Other supporting changes: - Add more documentation for terminal execution commands. - Refactor terminal script file execution into a dedicated file for improved separation of concerns. - Refactor naming of `RuntimeEnvironment` to align with naming conventions (no interface with I prefix) and for clarity. - Refactor `TemporaryFileCodeRunner` to simplify it by removing the `os` parameter and handling OS-specific logic within the filename generator instead. - Refactor `fileName` to `filename` for consistency. --- src/application/CodeRunner.ts | 3 - .../Context/ApplicationContextFactory.ts | 4 +- .../Execution/ScriptFileExecutor.ts | 3 + .../VisibleTerminalScriptFileExecutor.ts | 117 ++++++++++ .../CodeRunner/Filename/FilenameGenerator.ts | 3 + .../OsTimestampedFilenameGenerator.ts} | 26 ++- .../SystemOperations/NodeSystemOperations.ts | 2 +- .../SystemOperations/SystemOperations.ts | 2 +- .../CodeRunner/TemporaryFileCodeRunner.ts | 61 +---- .../HostRuntimeEnvironment.ts | 51 ++++ .../RuntimeEnvironment/IRuntimeEnvironment.ts | 7 - .../RuntimeEnvironment/RuntimeEnvironment.ts | 51 +--- .../bootstrapping/ClientLoggerFactory.ts | 6 +- .../bootstrapping/DependencyProvider.ts | 4 +- .../MobileSafariActivePseudoClassEnabler.ts | 10 +- .../Code/CodeButtons/CodeRunButton.vue | 2 - .../Shared/Hooks/UseRuntimeEnvironment.ts | 4 +- ...bileSafariActivePseudoClassEnabler.spec.ts | 4 +- .../VisibleTerminalScriptFileExecutor.spec.ts | 203 ++++++++++++++++ .../OsTimestampedFilenameGenerator.spec.ts} | 65 ++++-- .../TemporaryFileCodeRunner.spec.ts | 219 ++++++------------ .../RuntimeEnvironment.spec.ts | 6 +- .../bootstrapping/ClientLoggerFactory.spec.ts | 6 +- tests/unit/shared/Stubs/CommandOpsStub.ts | 4 +- .../shared/Stubs/FilenameGeneratorStub.ts | 14 ++ .../shared/Stubs/RuntimeEnvironmentStub.ts | 4 +- .../shared/Stubs/ScriptFileExecutorStub.ts | 14 ++ 27 files changed, 576 insertions(+), 319 deletions(-) create mode 100644 src/infrastructure/CodeRunner/Execution/ScriptFileExecutor.ts create mode 100644 src/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.ts create mode 100644 src/infrastructure/CodeRunner/Filename/FilenameGenerator.ts rename src/infrastructure/CodeRunner/{FileNameGenerator.ts => Filename/OsTimestampedFilenameGenerator.ts} (50%) create mode 100644 src/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment.ts delete mode 100644 src/infrastructure/RuntimeEnvironment/IRuntimeEnvironment.ts create mode 100644 tests/unit/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.spec.ts rename tests/unit/infrastructure/CodeRunner/{FileNameGenerator.spec.ts => Filename/OsTimestampedFilenameGenerator.spec.ts} (52%) create mode 100644 tests/unit/shared/Stubs/FilenameGeneratorStub.ts create mode 100644 tests/unit/shared/Stubs/ScriptFileExecutorStub.ts diff --git a/src/application/CodeRunner.ts b/src/application/CodeRunner.ts index 1add76dd..8985fb49 100644 --- a/src/application/CodeRunner.ts +++ b/src/application/CodeRunner.ts @@ -1,9 +1,6 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - export interface CodeRunner { runCode( code: string, tempScriptFolderName: string, - os: OperatingSystem, ): Promise; } diff --git a/src/application/Context/ApplicationContextFactory.ts b/src/application/Context/ApplicationContextFactory.ts index bfde26d6..fe4df97b 100644 --- a/src/application/Context/ApplicationContextFactory.ts +++ b/src/application/Context/ApplicationContextFactory.ts @@ -1,14 +1,14 @@ import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { IApplication } from '@/domain/IApplication'; -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; import { IApplicationFactory } from '../IApplicationFactory'; import { ApplicationFactory } from '../ApplicationFactory'; import { ApplicationContext } from './ApplicationContext'; export async function buildContext( factory: IApplicationFactory = ApplicationFactory.Current, - environment = RuntimeEnvironment.CurrentEnvironment, + environment = HostRuntimeEnvironment.CurrentEnvironment, ): Promise { const app = await factory.getApp(); const os = getInitialOs(app, environment.os); diff --git a/src/infrastructure/CodeRunner/Execution/ScriptFileExecutor.ts b/src/infrastructure/CodeRunner/Execution/ScriptFileExecutor.ts new file mode 100644 index 00000000..a4dde93b --- /dev/null +++ b/src/infrastructure/CodeRunner/Execution/ScriptFileExecutor.ts @@ -0,0 +1,3 @@ +export interface ScriptFileExecutor { + executeScriptFile(filePath: string): Promise; +} diff --git a/src/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.ts b/src/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.ts new file mode 100644 index 00000000..5ac40adb --- /dev/null +++ b/src/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.ts @@ -0,0 +1,117 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { CommandOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; +import { Logger } from '@/application/Common/Log/Logger'; +import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; +import { createNodeSystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations'; +import { ScriptFileExecutor } from './ScriptFileExecutor'; + +export class VisibleTerminalScriptExecutor implements ScriptFileExecutor { + constructor( + private readonly system: SystemOperations = createNodeSystemOperations(), + private readonly logger: Logger = ElectronLogger, + private readonly environment: RuntimeEnvironment = HostRuntimeEnvironment.CurrentEnvironment, + ) { } + + public async executeScriptFile(filePath: string): Promise { + const { os } = this.environment; + if (os === undefined) { + throw new Error('Unknown operating system'); + } + await this.setFileExecutablePermissions(filePath); + await this.runFileWithRunner(filePath, os); + } + + private async setFileExecutablePermissions(filePath: string): Promise { + this.logger.info(`Setting execution permissions for file at ${filePath}`); + await this.system.fileSystem.setFilePermissions(filePath, '755'); + this.logger.info(`Execution permissions set successfully for ${filePath}`); + } + + private async runFileWithRunner(filePath: string, os: OperatingSystem): Promise { + this.logger.info(`Executing script file: ${filePath} on ${OperatingSystem[os]}.`); + const runner = TerminalRunners[os]; + if (!runner) { + throw new Error(`Unsupported operating system: ${OperatingSystem[os]}`); + } + const context: TerminalExecutionContext = { + scriptFilePath: filePath, + commandOps: this.system.command, + logger: this.logger, + }; + await runner(context); + this.logger.info('Command script file successfully.'); + } +} + +interface TerminalExecutionContext { + readonly scriptFilePath: string; + readonly commandOps: CommandOps; + readonly logger: Logger; +} + +type TerminalRunner = (context: TerminalExecutionContext) => Promise; + +const TerminalRunners: Partial> = { + [OperatingSystem.Windows]: async (context) => { + /* + Options: + "path": + ✅ Launches the script within `cmd.exe`. + ✅ Uses user-friendly GUI sudo prompt. + */ + const command = cmdShellPathArgumentEscape(context.scriptFilePath); + await runCommand(command, context); + }, + [OperatingSystem.Linux]: async (context) => { + const command = `x-terminal-emulator -e ${posixShellPathArgumentEscape(context.scriptFilePath)}`; + /* + Options: + `x-terminal-emulator -e`: + ✅ Launches the script within the default terminal emulator. + ❌ Requires terminal-based (not GUI) sudo prompt, which may not be very user friendly. + */ + await runCommand(command, context); + }, + [OperatingSystem.macOS]: async (context) => { + const command = `open -a Terminal.app ${posixShellPathArgumentEscape(context.scriptFilePath)}`; + /* + Options: + `open -a Terminal.app`: + ✅ Launches the script within Terminal app, that exists natively in all modern macOS + versions. + ❌ Requires terminal-based (not GUI) sudo prompt, which may not be very user friendly. + ❌ Terminal app requires many privileges to execute the script, this would prompt user + to grant privileges to the Terminal app. + + `osascript -e "do shell script \\"${scriptPath}\\" with administrator privileges"`: + ✅ Uses user-friendly GUI sudo prompt. + ❌ Executes the script in the background, which does not provide the user with immediate + visual feedback or allow interaction with the script as it runs. + */ + await runCommand(command, context); + }, +} as const; + +async function runCommand(command: string, context: TerminalExecutionContext): Promise { + context.logger.info(`Executing command:\n${command}`); + await context.commandOps.exec(command); + context.logger.info('Executed command successfully.'); +} + +function posixShellPathArgumentEscape(pathArgument: string): string { + // - Wraps the path in single quotes, which is a standard practice in POSIX shells + // (like bash and zsh) found on macOS/Linux to ensure that characters like spaces, '*', and + // '?' are treated as literals, not as special characters. + // - Escapes any single quotes within the path itself. This allows paths containing single + // quotes to be correctly interpreted in POSIX-compliant systems, such as Linux and macOS. + return `'${pathArgument.replaceAll('\'', '\'\\\'\'')}'`; +} + +function cmdShellPathArgumentEscape(pathArgument: string): string { + // - Encloses the path in double quotes, which is necessary for Windows command line (cmd.exe) + // to correctly handle paths containing spaces. + // - Paths in Windows cannot include double quotes `"` themselves, so these are not escaped. + return `"${pathArgument}"`; +} diff --git a/src/infrastructure/CodeRunner/Filename/FilenameGenerator.ts b/src/infrastructure/CodeRunner/Filename/FilenameGenerator.ts new file mode 100644 index 00000000..be5428f1 --- /dev/null +++ b/src/infrastructure/CodeRunner/Filename/FilenameGenerator.ts @@ -0,0 +1,3 @@ +export interface FilenameGenerator { + generateFilename(): string; +} diff --git a/src/infrastructure/CodeRunner/FileNameGenerator.ts b/src/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.ts similarity index 50% rename from src/infrastructure/CodeRunner/FileNameGenerator.ts rename to src/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.ts index 3770794e..d78b75f0 100644 --- a/src/infrastructure/CodeRunner/FileNameGenerator.ts +++ b/src/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.ts @@ -1,4 +1,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; +import { FilenameGenerator } from './FilenameGenerator'; /** * Generates a timestamped filename specific to the given operating system. @@ -7,13 +10,22 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; * - A timestamp for uniqueness and easier auditability. * - File extension based on the operating system. */ -export function generateOsTimestampedFileName( - currentOs: OperatingSystem, - date = new Date(), -): string { - const baseFileName = `run-${createTimeStampForFile(date)}`; - const extension = FileExtensions[currentOs]; - return extension ? `${baseFileName}.${extension}` : baseFileName; +export class OsTimestampedFilenameGenerator implements FilenameGenerator { + private readonly currentOs?: OperatingSystem; + + constructor( + environment: RuntimeEnvironment = HostRuntimeEnvironment.CurrentEnvironment, + ) { + this.currentOs = environment.os; + } + + public generateFilename( + date = new Date(), + ): string { + const baseFileName = `run-${createTimeStampForFile(date)}`; + const extension = this.currentOs === undefined ? undefined : FileExtensions[this.currentOs]; + return extension ? `${baseFileName}.${extension}` : baseFileName; + } } const FileExtensions: Partial> = { diff --git a/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts b/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts index 88cbf747..28f74d5a 100644 --- a/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts +++ b/src/infrastructure/CodeRunner/SystemOperations/NodeSystemOperations.ts @@ -33,7 +33,7 @@ export function createNodeSystemOperations(): SystemOperations { ) => writeFile(filePath, data), }, command: { - execute: (command) => new Promise((resolve, reject) => { + exec: (command) => new Promise((resolve, reject) => { exec(command, (error) => { if (error) { reject(error); diff --git a/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts b/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts index 0d2d9d1f..9ba1d4da 100644 --- a/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts +++ b/src/infrastructure/CodeRunner/SystemOperations/SystemOperations.ts @@ -14,7 +14,7 @@ export interface LocationOps { } export interface CommandOps { - execute(command: string): Promise; + exec(command: string): Promise; } export interface FileSystemOps { diff --git a/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts index f1e52f08..7957cc8e 100644 --- a/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts +++ b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts @@ -1,30 +1,30 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; import { CodeRunner } from '@/application/CodeRunner'; import { Logger } from '@/application/Common/Log/Logger'; import { ElectronLogger } from '../Log/ElectronLogger'; import { SystemOperations } from './SystemOperations/SystemOperations'; import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations'; -import { generateOsTimestampedFileName } from './FileNameGenerator'; - -export type FileNameGenerator = (os: OperatingSystem) => string; +import { FilenameGenerator } from './Filename/FilenameGenerator'; +import { ScriptFileExecutor } from './Execution/ScriptFileExecutor'; +import { VisibleTerminalScriptExecutor } from './Execution/VisibleTerminalScriptFileExecutor'; +import { OsTimestampedFilenameGenerator } from './Filename/OsTimestampedFilenameGenerator'; export class TemporaryFileCodeRunner implements CodeRunner { constructor( private readonly system: SystemOperations = createNodeSystemOperations(), - private readonly fileNameGenerator: FileNameGenerator = generateOsTimestampedFileName, + private readonly filenameGenerator: FilenameGenerator = new OsTimestampedFilenameGenerator(), private readonly logger: Logger = ElectronLogger, + private readonly scriptFileExecutor: ScriptFileExecutor = new VisibleTerminalScriptExecutor(), ) { } public async runCode( code: string, tempScriptFolderName: string, - os: OperatingSystem, ): Promise { - this.logger.info(`Starting running code for OS: ${OperatingSystem[os]}`); + this.logger.info('Starting running code.'); try { - const fileName = this.fileNameGenerator(os); - const filePath = await this.createTemporaryFile(fileName, tempScriptFolderName, code); - await this.executeFile(filePath, os); + const filename = this.filenameGenerator.generateFilename(); + const filePath = await this.createTemporaryFile(filename, tempScriptFolderName, code); + await this.scriptFileExecutor.executeScriptFile(filePath); this.logger.info(`Successfully executed script at ${filePath}`); } catch (error) { this.logger.error(`Error executing script: ${error.message}`, error); @@ -33,7 +33,7 @@ export class TemporaryFileCodeRunner implements CodeRunner { } private async createTemporaryFile( - fileName: string, + filename: string, tempScriptFolderName: string, contents: string, ): Promise { @@ -42,7 +42,7 @@ export class TemporaryFileCodeRunner implements CodeRunner { tempScriptFolderName, ); await this.createDirectoryIfNotExists(directoryPath); - const filePath = this.system.location.combinePaths(directoryPath, fileName); + const filePath = this.system.location.combinePaths(directoryPath, filename); await this.createFile(filePath, contents); return filePath; } @@ -58,41 +58,4 @@ export class TemporaryFileCodeRunner implements CodeRunner { await this.system.fileSystem.createDirectory(directoryPath, true); this.logger.info(`Directory confirmed at: ${directoryPath}`); } - - private async executeFile(filePath: string, os: OperatingSystem): Promise { - await this.setFileExecutablePermissions(filePath); - const command = getExecuteCommand(filePath, os); - await this.executeCommand(command); - } - - private async setFileExecutablePermissions(filePath: string): Promise { - this.logger.info(`Setting execution permissions for file at ${filePath}`); - await this.system.fileSystem.setFilePermissions(filePath, '755'); - this.logger.info(`Execution permissions set successfully for ${filePath}`); - } - - private async executeCommand(command: string): Promise { - this.logger.info(`Executing command: ${command}`); - await this.system.command.execute(command); - this.logger.info('Command executed successfully.'); - } -} - -function getExecuteCommand( - scriptPath: string, - os: OperatingSystem, -): string { - switch (os) { - case OperatingSystem.Linux: - return `x-terminal-emulator -e '${scriptPath}'`; - case OperatingSystem.macOS: - return `open -a Terminal.app ${scriptPath}`; - // Another option with graphical sudo would be - // `osascript -e "do shell script \\"${scriptPath}\\" with administrator privileges"` - // However it runs in background - case OperatingSystem.Windows: - return scriptPath; - default: - throw Error(`unsupported os: ${OperatingSystem[os]}`); - } } diff --git a/src/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment.ts b/src/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment.ts new file mode 100644 index 00000000..35cdfd89 --- /dev/null +++ b/src/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment.ts @@ -0,0 +1,51 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; +import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; +import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; +import { ConditionBasedOsDetector } from './BrowserOs/ConditionBasedOsDetector'; +import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; +import { RuntimeEnvironment } from './RuntimeEnvironment'; +import { isTouchEnabledDevice } from './TouchSupportDetection'; + +export class HostRuntimeEnvironment implements RuntimeEnvironment { + public static readonly CurrentEnvironment + : RuntimeEnvironment = new HostRuntimeEnvironment(window); + + public readonly isDesktop: boolean; + + public readonly os: OperatingSystem | undefined; + + public readonly isNonProduction: boolean; + + protected constructor( + window: Partial, + environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance, + browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(), + 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); + } + } + } +} + +function getUserAgent(window: Partial): string | undefined { + return window?.navigator?.userAgent; +} + +function isDesktop(window: Partial): boolean { + return window?.isDesktop === true; +} diff --git a/src/infrastructure/RuntimeEnvironment/IRuntimeEnvironment.ts b/src/infrastructure/RuntimeEnvironment/IRuntimeEnvironment.ts deleted file mode 100644 index 52b5ea85..00000000 --- a/src/infrastructure/RuntimeEnvironment/IRuntimeEnvironment.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - -export interface IRuntimeEnvironment { - readonly isDesktop: boolean; - readonly os: OperatingSystem | undefined; - readonly isNonProduction: boolean; -} diff --git a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts index 6e8cac66..009a9513 100644 --- a/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts +++ b/src/infrastructure/RuntimeEnvironment/RuntimeEnvironment.ts @@ -1,50 +1,7 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; -import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables'; -import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; -import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; -import { ConditionBasedOsDetector } from './BrowserOs/ConditionBasedOsDetector'; -import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; -import { IRuntimeEnvironment } from './IRuntimeEnvironment'; -import { isTouchEnabledDevice } from './TouchSupportDetection'; -export class RuntimeEnvironment implements IRuntimeEnvironment { - public static readonly CurrentEnvironment: IRuntimeEnvironment = new RuntimeEnvironment(window); - - public readonly isDesktop: boolean; - - public readonly os: OperatingSystem | undefined; - - public readonly isNonProduction: boolean; - - protected constructor( - window: Partial, - environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance, - browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(), - 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); - } - } - } -} - -function getUserAgent(window: Partial): string | undefined { - return window?.navigator?.userAgent; -} - -function isDesktop(window: Partial): boolean { - return window?.isDesktop === true; +export interface RuntimeEnvironment { + readonly isDesktop: boolean; + readonly os: OperatingSystem | undefined; + readonly isNonProduction: boolean; } diff --git a/src/presentation/bootstrapping/ClientLoggerFactory.ts b/src/presentation/bootstrapping/ClientLoggerFactory.ts index c290103d..58486b4e 100644 --- a/src/presentation/bootstrapping/ClientLoggerFactory.ts +++ b/src/presentation/bootstrapping/ClientLoggerFactory.ts @@ -1,5 +1,5 @@ +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; -import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger'; import { Logger } from '@/application/Common/Log/Logger'; import { LoggerFactory } from '@/application/Common/Log/LoggerFactory'; @@ -11,7 +11,9 @@ export class ClientLoggerFactory implements LoggerFactory { public readonly logger: Logger; - protected constructor(environment: IRuntimeEnvironment = RuntimeEnvironment.CurrentEnvironment) { + protected constructor( + environment: RuntimeEnvironment = HostRuntimeEnvironment.CurrentEnvironment, + ) { if (environment.isDesktop) { this.logger = new WindowInjectedLogger(); return; diff --git a/src/presentation/bootstrapping/DependencyProvider.ts b/src/presentation/bootstrapping/DependencyProvider.ts index 72124c9a..063a8e25 100644 --- a/src/presentation/bootstrapping/DependencyProvider.ts +++ b/src/presentation/bootstrapping/DependencyProvider.ts @@ -5,7 +5,6 @@ import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hook import { useClipboard } from '@/presentation/components/Shared/Hooks/Clipboard/UseClipboard'; import { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode'; import { IApplicationContext } from '@/application/Context/IApplicationContext'; -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { AnyLifetimeInjectionKey, InjectionKeySelector, InjectionKeys, SingletonKey, TransientKey, injectKey, @@ -14,6 +13,7 @@ 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'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; export function provideDependencies( context: IApplicationContext, @@ -33,7 +33,7 @@ export function provideDependencies( ), useRuntimeEnvironment: (di) => di.provide( InjectionKeys.useRuntimeEnvironment, - RuntimeEnvironment.CurrentEnvironment, + HostRuntimeEnvironment.CurrentEnvironment, ), useAutoUnsubscribedEvents: (di) => di.provide( InjectionKeys.useAutoUnsubscribedEvents, diff --git a/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts b/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts index 63a737d2..fead6df9 100644 --- a/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts +++ b/src/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.ts @@ -1,11 +1,11 @@ -import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; import { OperatingSystem } from '@/domain/OperatingSystem'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { Bootstrapper } from '../Bootstrapper'; export class MobileSafariActivePseudoClassEnabler implements Bootstrapper { constructor( - private readonly currentEnvironment = RuntimeEnvironment.CurrentEnvironment, + private readonly currentEnvironment = HostRuntimeEnvironment.CurrentEnvironment, private readonly browser: BrowserAccessor = GlobalBrowserAccessor, ) { @@ -42,14 +42,14 @@ export interface BrowserAccessor { addWindowEventListener(...args: Parameters): void; } -function isMobileSafari(environment: IRuntimeEnvironment, userAgent: string): boolean { +function isMobileSafari(environment: RuntimeEnvironment, userAgent: string): boolean { if (!isMobileAppleOperatingSystem(environment)) { return false; } return isSafari(userAgent); } -function isMobileAppleOperatingSystem(environment: IRuntimeEnvironment): boolean { +function isMobileAppleOperatingSystem(environment: RuntimeEnvironment): boolean { if (environment.os === undefined) { return false; } diff --git a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue index 421f5dc1..d614fb84 100644 --- a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue +++ b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue @@ -26,11 +26,9 @@ export default defineComponent({ async function executeCode() { 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, - os, ); } diff --git a/src/presentation/components/Shared/Hooks/UseRuntimeEnvironment.ts b/src/presentation/components/Shared/Hooks/UseRuntimeEnvironment.ts index 35f01bc7..d417e943 100644 --- a/src/presentation/components/Shared/Hooks/UseRuntimeEnvironment.ts +++ b/src/presentation/components/Shared/Hooks/UseRuntimeEnvironment.ts @@ -1,5 +1,5 @@ -import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; -export function useRuntimeEnvironment(environment: IRuntimeEnvironment) { +export function useRuntimeEnvironment(environment: RuntimeEnvironment) { return environment; } diff --git a/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts b/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts index 22d272e9..59ee1a97 100644 --- a/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts +++ b/tests/integration/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler.spec.ts @@ -3,8 +3,8 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { MobileSafariActivePseudoClassEnabler } from '@/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler'; import { EventName, createWindowEventSpies } from '@tests/shared/Spies/WindowEventSpies'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { isTouchEnabledDevice } from '@/infrastructure/RuntimeEnvironment/TouchSupportDetection'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; import { MobileSafariDetectionTestCases } from './MobileSafariDetectionTestCases'; describe('MobileSafariActivePseudoClassEnabler', () => { @@ -59,7 +59,7 @@ function getTouchDetectorMock( return () => isTouchEnabled; } -class ConstructibleRuntimeEnvironment extends RuntimeEnvironment { +class ConstructibleRuntimeEnvironment extends HostRuntimeEnvironment { public constructor(isTouchEnabled: boolean) { super(window, undefined, undefined, getTouchDetectorMock(isTouchEnabled)); } diff --git a/tests/unit/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.spec.ts b/tests/unit/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.spec.ts new file mode 100644 index 00000000..eeb38016 --- /dev/null +++ b/tests/unit/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems'; +import { VisibleTerminalScriptExecutor } from '@/infrastructure/CodeRunner/Execution/VisibleTerminalScriptFileExecutor'; +import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; +import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; +import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; +import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub'; +import { SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; +import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub'; + +describe('VisibleTerminalScriptFileExecutor', () => { + describe('executeScriptFile', () => { + describe('throws error for invalid operating systems', () => { + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly invalidOs?: OperatingSystem; + readonly expectedError: string; + }> = [ + (() => { + const unsupportedOs = OperatingSystem.Android; + return { + description: 'unsupported OS', + invalidOs: unsupportedOs, + expectedError: `Unsupported operating system: ${OperatingSystem[unsupportedOs]}`, + }; + })(), + { + description: 'undefined OS', + invalidOs: undefined, + expectedError: 'Unknown operating system', + }, + ]; + testScenarios.forEach(({ description, invalidOs, expectedError }) => { + it(description, async () => { + // arrange + const context = new ScriptFileTestSetup() + .withOs(invalidOs); + // act + const act = async () => { await context.executeScriptFile(); }; + // assert + await expectThrowsAsync(act, expectedError); + }); + }); + }); + describe('command execution', () => { + // arrange + const testScenarios: Record = { + [OperatingSystem.Windows]: [ + { + description: 'encloses path in quotes', + filePath: 'file', + expectedCommand: '"file"', + }, + ], + [OperatingSystem.macOS]: [ + { + description: 'encloses path in quotes', + filePath: 'file', + expectedCommand: 'open -a Terminal.app \'file\'', + }, + { + description: 'escapes single quotes in path', + filePath: 'f\'i\'le', + expectedCommand: 'open -a Terminal.app \'f\'\\\'\'i\'\\\'\'le\'', + }, + ], + [OperatingSystem.Linux]: [ + { + description: 'encloses path in quotes', + filePath: 'file', + expectedCommand: 'x-terminal-emulator -e \'file\'', + }, + { + description: 'escapes single quotes in path', + filePath: 'f\'i\'le', + expectedCommand: 'x-terminal-emulator -e \'f\'\\\'\'i\'\\\'\'le\'', + }, + ], + }; + AllSupportedOperatingSystems.forEach((operatingSystem) => { + describe(`on ${OperatingSystem[operatingSystem]}`, () => { + testScenarios[operatingSystem].forEach(( + { description, filePath, expectedCommand }, + ) => { + it(description, async () => { + // arrange + const command = new CommandOpsStub(); + const context = new ScriptFileTestSetup() + .withOs(operatingSystem) + .withFilePath(filePath) + .withSystemOperations(new SystemOperationsStub().withCommand(command)); + + // act + await context.executeScriptFile(); + + // assert + const calls = command.callHistory.filter((c) => c.methodName === 'exec'); + expect(calls.length).to.equal(1); + const [actualCommand] = calls[0].args; + expect(actualCommand).to.equal(expectedCommand); + }); + }); + }); + }); + }); + describe('file permissions', () => { + it('sets permissions before execution', async () => { + // arrange + let isExecutedAfterPermissions = false; + let isPermissionsSet = false; + const fileSystemMock = new FileSystemOpsStub(); + fileSystemMock.setFilePermissions = () => { + isPermissionsSet = true; + return Promise.resolve(); + }; + const commandMock = new CommandOpsStub(); + commandMock.exec = () => { + isExecutedAfterPermissions = isPermissionsSet; + return Promise.resolve(); + }; + const context = new ScriptFileTestSetup() + .withSystemOperations(new SystemOperationsStub() + .withFileSystem(fileSystemMock) + .withCommand(commandMock)); + + // act + await context.executeScriptFile(); + + // assert + expect(isExecutedAfterPermissions).to.equal(true); + }); + it('applies correct permissions', async () => { + // arrange + const expectedMode = '755'; + const fileSystem = new FileSystemOpsStub(); + const context = new ScriptFileTestSetup() + .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); + + // act + await context.executeScriptFile(); + + // assert + const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); + expect(calls.length).to.equal(1); + const [, actualMode] = calls[0].args; + expect(actualMode).to.equal(expectedMode); + }); + it('sets permissions on the correct file', async () => { + // arrange + const expectedFilePath = 'expected-file-path'; + const fileSystem = new FileSystemOpsStub(); + const context = new ScriptFileTestSetup() + .withFilePath(expectedFilePath) + .withSystemOperations(new SystemOperationsStub().withFileSystem(fileSystem)); + + // act + await context.executeScriptFile(); + + // assert + const calls = fileSystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); + expect(calls.length).to.equal(1); + const [actualFilePath] = calls[0].args; + expect(actualFilePath).to.equal(expectedFilePath); + }); + }); + }); +}); + +class ScriptFileTestSetup { + private os?: OperatingSystem = OperatingSystem.Windows; + + private filePath = `[${ScriptFileTestSetup.name}] file path`; + + private system: SystemOperations = new SystemOperationsStub(); + + public withOs(os: OperatingSystem | undefined): this { + this.os = os; + return this; + } + + public withSystemOperations(system: SystemOperations): this { + this.system = system; + return this; + } + + public withFilePath(filePath: string): this { + this.filePath = filePath; + return this; + } + + public executeScriptFile(): Promise { + const environment = new RuntimeEnvironmentStub().withOs(this.os); + const logger = new LoggerStub(); + const executor = new VisibleTerminalScriptExecutor(this.system, logger, environment); + return executor.executeScriptFile(this.filePath); + } +} diff --git a/tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts b/tests/unit/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.spec.ts similarity index 52% rename from tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts rename to tests/unit/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.spec.ts index d6def7e0..c9c14e1e 100644 --- a/tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts +++ b/tests/unit/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator.spec.ts @@ -1,18 +1,19 @@ import { describe, it, expect } from 'vitest'; import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { generateOsTimestampedFileName } from '@/infrastructure/CodeRunner/FileNameGenerator'; +import { OsTimestampedFilenameGenerator } from '@/infrastructure/CodeRunner/Filename/OsTimestampedFilenameGenerator'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; +import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub'; -describe('FileNameGenerator', () => { - describe('generateOsTimestampedFileName', () => { +describe('OsTimestampedFilenameGenerator', () => { + describe('generateFilename', () => { it('generates correct prefix', () => { // arrange const expectedPrefix = 'run'; // act - const fileName = generateFileNamePartsForTesting(); + const filename = generateFilenamePartsForTesting(); // assert - expect(fileName.prefix).to.equal(expectedPrefix); + expect(filename.prefix).to.equal(expectedPrefix); }); it('generates correct timestamp', () => { // arrange @@ -20,10 +21,10 @@ describe('FileNameGenerator', () => { const expectedTimestamp = '2023-01-01_12-00-00'; const date = new Date(currentDate); // act - const fileName = generateFileNamePartsForTesting({ date }); + const filename = generateFilenamePartsForTesting({ date }); // assert - expect(fileName.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[ - `Generated file name: ${fileName.generatedFileName}` + expect(filename.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[ + `Generated file name: ${filename.generatedFileName}` ]); }); describe('generates correct extension', () => { @@ -37,21 +38,37 @@ describe('FileNameGenerator', () => { // arrange const expectedExtension = testScenarios[operatingSystem]; // act - const fileName = generateFileNamePartsForTesting({ operatingSystem }); + const filename = generateFilenamePartsForTesting({ operatingSystem }); // assert - expect(fileName.extension).to.equal(expectedExtension, formatAssertionMessage[ - `Generated file name: ${fileName.generatedFileName}` + expect(filename.extension).to.equal(expectedExtension, formatAssertionMessage[ + `Generated file name: ${filename.generatedFileName}` ]); }); }); }); - it('generates filename without extension for unknown OS', () => { + describe('generates filename without extension for unknown OS', () => { // arrange - const unknownOs = 'Unknown' as unknown as OperatingSystem; - // act - const fileName = generateFileNamePartsForTesting({ operatingSystem: unknownOs }); - // assert - expect(fileName.extension).toBeUndefined(); + const testScenarios: ReadonlyArray<{ + readonly description: string; + readonly unknownOs?: OperatingSystem; + }> = [ + { + description: 'unsupported OS', + unknownOs: 'Unsupported' as unknown as OperatingSystem, + }, + { + description: 'undefined OS', + unknownOs: undefined, + }, + ]; + testScenarios.forEach(({ description, unknownOs }) => { + it(description, () => { + // act + const filename = generateFilenamePartsForTesting({ operatingSystem: unknownOs }); + // assert + expect(filename.extension).toBeUndefined(); + }); + }); }); }); }); @@ -63,20 +80,22 @@ interface TestFileNameComponents { readonly generatedFileName: string; } -function generateFileNamePartsForTesting(testScenario?: { +function generateFilenamePartsForTesting(testScenario?: { operatingSystem?: OperatingSystem, date?: Date, }): TestFileNameComponents { - const operatingSystem = testScenario?.operatingSystem ?? OperatingSystem.Windows; const date = testScenario?.date ?? new Date(); - const fileName = generateOsTimestampedFileName(operatingSystem, date); + const sut = new OsTimestampedFilenameGenerator( + new RuntimeEnvironmentStub().withOs(testScenario?.operatingSystem), + ); + const filename = sut.generateFilename(date); const pattern = /^(?[^-]+)-(?[^.]+)(?:\.(?[^.]+))?$/; // prefix-timestamp.extension - const match = fileName.match(pattern); + const match = filename.match(pattern); if (!match?.groups?.prefix || !match?.groups?.timestamp) { - throw new Error(`Failed to parse prefix or timestamp: ${fileName}`); + throw new Error(`Failed to parse prefix or timestamp: ${filename}`); } return { - generatedFileName: fileName, + generatedFileName: filename, prefix: match.groups.prefix, timestamp: match.groups.timestamp, extension: match.groups.extension, diff --git a/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts b/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts index 7938e41b..46a5aa11 100644 --- a/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts +++ b/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts @@ -1,18 +1,18 @@ import { describe, it, expect } from 'vitest'; -import { OperatingSystem } from '@/domain/OperatingSystem'; import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; -import { FileNameGenerator, TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner'; -import { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync'; +import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner'; 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 { FunctionKeys } from '@/TypeHelpers'; -import { AllSupportedOperatingSystems, SupportedOperatingSystem } from '@tests/shared/TestCases/SupportedOperatingSystems'; import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub'; import { Logger } from '@/application/Common/Log/Logger'; +import { FilenameGenerator } from '@/infrastructure/CodeRunner/Filename/FilenameGenerator'; +import { FilenameGeneratorStub } from '@tests/unit/shared/Stubs/FilenameGeneratorStub'; +import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor'; +import { ScriptFileExecutorStub } from '@tests/unit/shared/Stubs/ScriptFileExecutorStub'; +import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub'; describe('TemporaryFileCodeRunner', () => { describe('runCode', () => { @@ -69,26 +69,6 @@ describe('TemporaryFileCodeRunner', () => { const [, actualData] = calls[0].args; expect(actualData).to.equal(expectedCode); }); - it('generates file name for correct operating system', async () => { - // arrange - const expectedOperatingSystem = OperatingSystem.macOS; - const calls = new Array(); - const fileNameGenerator: FileNameGenerator = (operatingSystem) => { - calls.push(operatingSystem); - return 'unimportant file name'; - }; - const context = new CodeRunnerTestSetup() - .withOs(expectedOperatingSystem) - .withFileNameGenerator(fileNameGenerator); - - // act - await context.runCode(); - - // assert - expect(calls).to.have.lengthOf(1); - const [actualOperatingSystem] = calls; - expect(actualOperatingSystem).to.equal(expectedOperatingSystem); - }); it('creates file in expected directory', async () => { // arrange const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/'; @@ -128,9 +108,9 @@ describe('TemporaryFileCodeRunner', () => { // arrange const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/'; const filesystem = new FileSystemOpsStub(); - const expectedFileName = 'expected-script-file-name'; + const expectedFilename = 'expected-script-file-name'; const context = new CodeRunnerTestSetup() - .withFileNameGenerator(() => expectedFileName) + .withFileNameGenerator(new FilenameGeneratorStub().withFilename(expectedFilename)) .withSystemOperationsStub((ops) => ops .withFileSystem(filesystem) .withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator))); @@ -149,99 +129,10 @@ describe('TemporaryFileCodeRunner', () => { `Actual file path: ${actualFilePath}`, ])); }); - }); - describe('file permissions', () => { - it('sets correct permissions', async () => { - // arrange - const expectedMode = '755'; - const filesystem = new FileSystemOpsStub(); - const context = new CodeRunnerTestSetup() - .withSystemOperationsStub((ops) => ops.withFileSystem(filesystem)); - - // act - await context.runCode(); - - // assert - const calls = filesystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); - expect(calls.length).to.equal(1); - const [, actualMode] = calls[0].args; - expect(actualMode).to.equal(expectedMode); - }); - it('sets correct permissions on correct file', async () => { - // arrange - const expectedFilePath = 'expected-file-path'; - const filesystem = new FileSystemOpsStub(); - const extension = '.sh'; - const expectedName = `run.${extension}`; - const folderName = 'privacy.sexy'; - const temporaryDirName = 'tmp'; - const context = new CodeRunnerTestSetup() - .withSystemOperationsStub((ops) => ops - .withOperatingSystem( - new OperatingSystemOpsStub() - .withTemporaryDirectoryResult(temporaryDirName), - ) - .withLocation( - new LocationOpsStub() - .withJoinResult('folder', temporaryDirName, folderName) - .withJoinResult(expectedFilePath, 'folder', expectedName), - ) - .withFileSystem(filesystem)); - - // act - await context - .withFolderName(folderName) - .withFileNameGenerator(() => expectedName) - .runCode(); - - // assert - const calls = filesystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); - expect(calls.length).to.equal(1); - const [actualFilePath] = calls[0].args; - expect(actualFilePath).to.equal(expectedFilePath); - }); - }); - describe('file execution', () => { - describe('executes correct command', () => { - // arrange - const filePath = 'executed-file-path'; - const testScenarios: Record = { - [OperatingSystem.Windows]: filePath, - [OperatingSystem.macOS]: `open -a Terminal.app ${filePath}`, - [OperatingSystem.Linux]: `x-terminal-emulator -e '${filePath}'`, - }; - AllSupportedOperatingSystems.forEach((operatingSystem) => { - it(`returns correctly for ${OperatingSystem[operatingSystem]}`, async () => { - // arrange - const expectedCommand = testScenarios[operatingSystem]; - const command = new CommandOpsStub(); - const context = new CodeRunnerTestSetup() - .withFileNameGenerator(() => filePath) - .withSystemOperationsStub((ops) => ops - .withLocation( - new LocationOpsStub() - .withJoinResultSequence('non-important-folder-name', filePath), - ) - .withCommand(command)); - - // act - await context - .withOs(operatingSystem) - .runCode(); - - // assert - const calls = command.callHistory.filter((c) => c.methodName === 'execute'); - expect(calls.length).to.equal(1); - const [actualCommand] = calls[0].args; - expect(actualCommand).to.equal(expectedCommand); - }); - }); - }); - it('runs in expected order', async () => { // verifies correct `async`, `await` usage. + it('creates file after creating the directory', async () => { const expectedOrder: readonly FunctionKeys[] = [ 'createDirectory', 'writeToFile', - 'setFilePermissions', ]; const fileSystem = new FileSystemOpsStub(); const context = new CodeRunnerTestSetup() @@ -258,31 +149,50 @@ describe('TemporaryFileCodeRunner', () => { expect(expectedOrder).to.deep.equal(actualOrder); }); }); - describe('throws with invalid OS', () => { - const testScenarios: ReadonlyArray<{ - readonly description: string; - readonly invalidOs: OperatingSystem; - readonly expectedError: string; - }> = [ - (() => { - const unsupportedOs = OperatingSystem.Android; - return { - description: 'unsupported OS', - invalidOs: unsupportedOs, - expectedError: `unsupported os: ${OperatingSystem[unsupportedOs]}`, - }; - })(), - ]; - testScenarios.forEach(({ description, invalidOs, expectedError }) => { - it(description, async () => { - // arrange - const context = new CodeRunnerTestSetup() - .withOs(invalidOs); - // act - const act = async () => { await context.runCode(); }; - // assert - await expectThrowsAsync(act, expectedError); - }); + describe('file execution', () => { + it('executes correct file', async () => { + // arrange + const fileSystem = new FileSystemOpsStub(); + const fileExecutor = new ScriptFileExecutorStub(); + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops.withFileSystem(fileSystem)) + .withFileExecutor(fileExecutor); + + // act + await context.runCode(); + + // assert + const writeFileCalls = fileSystem.callHistory.filter((call) => call.methodName === 'writeToFile'); + expect(writeFileCalls.length).to.equal(1); + const [expectedFilePath] = writeFileCalls[0].args; + const execFileCalls = fileExecutor.callHistory.filter((call) => call.methodName === 'executeScriptFile'); + expect(execFileCalls.length).to.equal(1); + const [actualPath] = execFileCalls[0].args; + expect(actualPath).to.equal(expectedFilePath); + }); + it('executes after creating the file', async () => { + // arrange + let isFileCreated = false; + let isExecutedAfterCreation = false; + const filesystem = new FileSystemOpsStub(); + filesystem.writeToFile = () => { + isFileCreated = true; + return Promise.resolve(); + }; + const fileExecutor = new ScriptFileExecutorStub(); + fileExecutor.executeScriptFile = () => { + isExecutedAfterCreation = isFileCreated; + return Promise.resolve(); + }; + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops.withFileSystem(filesystem)) + .withFileExecutor(fileExecutor); + + // act + await context.runCode(); + + // assert + expect(isExecutedAfterCreation).to.equal(true); }); }); }); @@ -293,21 +203,22 @@ class CodeRunnerTestSetup { private folderName = `[${CodeRunnerTestSetup.name}]folderName`; - private fileNameGenerator: FileNameGenerator = () => `[${CodeRunnerTestSetup.name}]file-name-stub`; - - private os: OperatingSystem = OperatingSystem.Windows; + private filenameGenerator: FilenameGenerator = new FilenameGeneratorStub(); private systemOperations: SystemOperations = new SystemOperationsStub(); + private fileExecutor: ScriptFileExecutor = new ScriptFileExecutorStub(); + private logger: Logger = new LoggerStub(); public async runCode(): Promise { const runner = new TemporaryFileCodeRunner( this.systemOperations, - this.fileNameGenerator, + this.filenameGenerator, this.logger, + this.fileExecutor, ); - await runner.runCode(this.code, this.folderName, this.os); + await runner.runCode(this.code, this.folderName); } public withSystemOperations( @@ -324,13 +235,13 @@ class CodeRunnerTestSetup { return this.withSystemOperations(stub); } - public withOs(os: OperatingSystem): this { - this.os = os; + public withFolderName(folderName: string): this { + this.folderName = folderName; return this; } - public withFolderName(folderName: string): this { - this.folderName = folderName; + public withFileExecutor(fileExecutor: ScriptFileExecutor): this { + this.fileExecutor = fileExecutor; return this; } @@ -339,8 +250,8 @@ class CodeRunnerTestSetup { return this; } - public withFileNameGenerator(fileNameGenerator: FileNameGenerator): this { - this.fileNameGenerator = fileNameGenerator; + public withFileNameGenerator(fileNameGenerator: FilenameGenerator): this { + this.filenameGenerator = fileNameGenerator; return this; } } diff --git a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts index ef801aca..da92e083 100644 --- a/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts +++ b/tests/unit/infrastructure/RuntimeEnvironment/RuntimeEnvironment.spec.ts @@ -2,14 +2,14 @@ import { describe, it, expect } from 'vitest'; import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; +import { HostRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/HostRuntimeEnvironment'; import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub'; import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub'; import { expectExists } from '@tests/shared/Assertions/ExpectExists'; -describe('RuntimeEnvironment', () => { +describe('HostRuntimeEnvironment', () => { describe('ctor', () => { describe('throws if window is absent', () => { itEachAbsentObjectValue((absentValue) => { @@ -190,7 +190,7 @@ function createEnvironment(options: Partial = {}): TestableR return new TestableRuntimeEnvironment({ ...defaultOptions, ...options }); } -class TestableRuntimeEnvironment extends RuntimeEnvironment { +class TestableRuntimeEnvironment extends HostRuntimeEnvironment { /* Using a separate object instead of `ConstructorParameter<..>` */ public constructor(options: Required) { super( diff --git a/tests/unit/presentation/bootstrapping/ClientLoggerFactory.spec.ts b/tests/unit/presentation/bootstrapping/ClientLoggerFactory.spec.ts index 10cb3ad2..d4096296 100644 --- a/tests/unit/presentation/bootstrapping/ClientLoggerFactory.spec.ts +++ b/tests/unit/presentation/bootstrapping/ClientLoggerFactory.spec.ts @@ -1,7 +1,7 @@ import { describe, it, beforeEach, afterEach, } from 'vitest'; -import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory'; import { Logger } from '@/application/Common/Log/Logger'; import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger'; @@ -30,7 +30,7 @@ describe('ClientLoggerFactory', () => { const testCases: Array<{ readonly description: string, readonly expectedType: Constructible, - readonly environment: IRuntimeEnvironment, + readonly environment: RuntimeEnvironment, }> = [ { description: 'desktop environment', @@ -74,7 +74,7 @@ describe('ClientLoggerFactory', () => { }); class TestableClientLoggerFactory extends ClientLoggerFactory { - public constructor(environment: IRuntimeEnvironment) { + public constructor(environment: RuntimeEnvironment) { super(environment); } } diff --git a/tests/unit/shared/Stubs/CommandOpsStub.ts b/tests/unit/shared/Stubs/CommandOpsStub.ts index 1059b9ca..afcd50e6 100644 --- a/tests/unit/shared/Stubs/CommandOpsStub.ts +++ b/tests/unit/shared/Stubs/CommandOpsStub.ts @@ -4,9 +4,9 @@ import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; export class CommandOpsStub extends StubWithObservableMethodCalls implements CommandOps { - public execute(command: string): Promise { + public exec(command: string): Promise { this.registerMethodCall({ - methodName: 'execute', + methodName: 'exec', args: [command], }); return Promise.resolve(); diff --git a/tests/unit/shared/Stubs/FilenameGeneratorStub.ts b/tests/unit/shared/Stubs/FilenameGeneratorStub.ts new file mode 100644 index 00000000..0822b638 --- /dev/null +++ b/tests/unit/shared/Stubs/FilenameGeneratorStub.ts @@ -0,0 +1,14 @@ +import { FilenameGenerator } from '@/infrastructure/CodeRunner/Filename/FilenameGenerator'; + +export class FilenameGeneratorStub implements FilenameGenerator { + private filename = `[${FilenameGeneratorStub.name}]file-name-stub`; + + public generateFilename(): string { + return this.filename; + } + + public withFilename(filename: string): this { + this.filename = filename; + return this; + } +} diff --git a/tests/unit/shared/Stubs/RuntimeEnvironmentStub.ts b/tests/unit/shared/Stubs/RuntimeEnvironmentStub.ts index 1ace228b..514ad520 100644 --- a/tests/unit/shared/Stubs/RuntimeEnvironmentStub.ts +++ b/tests/unit/shared/Stubs/RuntimeEnvironmentStub.ts @@ -1,7 +1,7 @@ -import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment'; +import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import { OperatingSystem } from '@/domain/OperatingSystem'; -export class RuntimeEnvironmentStub implements IRuntimeEnvironment { +export class RuntimeEnvironmentStub implements RuntimeEnvironment { public isNonProduction = true; public isDesktop = true; diff --git a/tests/unit/shared/Stubs/ScriptFileExecutorStub.ts b/tests/unit/shared/Stubs/ScriptFileExecutorStub.ts new file mode 100644 index 00000000..99b20da9 --- /dev/null +++ b/tests/unit/shared/Stubs/ScriptFileExecutorStub.ts @@ -0,0 +1,14 @@ +import { ScriptFileExecutor } from '@/infrastructure/CodeRunner/Execution/ScriptFileExecutor'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export class ScriptFileExecutorStub + extends StubWithObservableMethodCalls + implements ScriptFileExecutor { + public executeScriptFile(filePath: string): Promise { + this.registerMethodCall({ + methodName: 'executeScriptFile', + args: [filePath], + }); + return Promise.resolve(); + } +}