diff --git a/src/application/CodeRunner.ts b/src/application/CodeRunner.ts index 6c08a6d2..1add76dd 100644 --- a/src/application/CodeRunner.ts +++ b/src/application/CodeRunner.ts @@ -2,6 +2,8 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; export interface CodeRunner { runCode( - code: string, folderName: string, fileExtension: string, os: OperatingSystem, + code: string, + tempScriptFolderName: string, + os: OperatingSystem, ): Promise; } diff --git a/src/infrastructure/CodeRunner/FileNameGenerator.ts b/src/infrastructure/CodeRunner/FileNameGenerator.ts new file mode 100644 index 00000000..3770794e --- /dev/null +++ b/src/infrastructure/CodeRunner/FileNameGenerator.ts @@ -0,0 +1,35 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +/** + * Generates a timestamped filename specific to the given operating system. + * + * The filename includes: + * - 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; +} + +const FileExtensions: Partial> = { + // '.bat' for Windows batch files; required for executability. + [OperatingSystem.Windows]: 'bat', + + // '.sh' for Unix-like systems; enhances recognition as a shell script + [OperatingSystem.macOS]: 'sh', + [OperatingSystem.Linux]: 'sh', +}; + +/** Generates a timestamp for the filename in 'YYYY-MM-DD_HH-MM-SS' format. */ +function createTimeStampForFile(date: Date): string { + return date + .toISOString() + .replace(/T/, '_') + .replace(/:/g, '-') + .replace(/\..+/, ''); +} diff --git a/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts index 5a2714b3..f1e52f08 100644 --- a/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts +++ b/src/infrastructure/CodeRunner/TemporaryFileCodeRunner.ts @@ -1,37 +1,88 @@ 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; export class TemporaryFileCodeRunner implements CodeRunner { constructor( private readonly system: SystemOperations = createNodeSystemOperations(), + private readonly fileNameGenerator: FileNameGenerator = generateOsTimestampedFileName, + private readonly logger: Logger = ElectronLogger, ) { } public async runCode( code: string, - folderName: string, - fileExtension: string, + tempScriptFolderName: string, os: OperatingSystem, ): Promise { - const dir = this.system.location.combinePaths( + this.logger.info(`Starting running code for OS: ${OperatingSystem[os]}`); + try { + const fileName = this.fileNameGenerator(os); + const filePath = await this.createTemporaryFile(fileName, tempScriptFolderName, code); + await this.executeFile(filePath, os); + this.logger.info(`Successfully executed script at ${filePath}`); + } catch (error) { + this.logger.error(`Error executing script: ${error.message}`, error); + throw error; + } + } + + private async createTemporaryFile( + fileName: string, + tempScriptFolderName: string, + contents: string, + ): Promise { + const directoryPath = this.system.location.combinePaths( this.system.operatingSystem.getTempDirectory(), - folderName, + tempScriptFolderName, ); - await this.system.fileSystem.createDirectory(dir, true); - const filePath = this.system.location.combinePaths(dir, `run.${fileExtension}`); - await this.system.fileSystem.writeToFile(filePath, code); - await this.system.fileSystem.setFilePermissions(filePath, '755'); + await this.createDirectoryIfNotExists(directoryPath); + const filePath = this.system.location.combinePaths(directoryPath, fileName); + await this.createFile(filePath, contents); + return filePath; + } + + private async createFile(filePath: string, contents: string): Promise { + this.logger.info(`Creating file at ${filePath}, size: ${contents.length} characters`); + await this.system.fileSystem.writeToFile(filePath, contents); + this.logger.info(`File created successfully at ${filePath}`); + } + + private async createDirectoryIfNotExists(directoryPath: string): Promise { + this.logger.info(`Checking and ensuring directory exists: ${directoryPath}`); + 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, - currentOperatingSystem: OperatingSystem, + os: OperatingSystem, ): string { - switch (currentOperatingSystem) { + switch (os) { case OperatingSystem.Linux: return `x-terminal-emulator -e '${scriptPath}'`; case OperatingSystem.macOS: @@ -42,6 +93,6 @@ function getExecuteCommand( case OperatingSystem.Windows: return scriptPath; default: - throw Error(`unsupported os: ${OperatingSystem[currentOperatingSystem]}`); + throw Error(`unsupported os: ${OperatingSystem[os]}`); } } diff --git a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue index 3a481251..421f5dc1 100644 --- a/src/presentation/components/Code/CodeButtons/CodeRunButton.vue +++ b/src/presentation/components/Code/CodeButtons/CodeRunButton.vue @@ -30,7 +30,6 @@ export default defineComponent({ await codeRunner.runCode( currentContext.state.code.current, currentContext.app.info.name, - currentState.value.collection.scripting.fileExtension, os, ); } diff --git a/tests/e2e/operating-system-selector.cy.ts b/tests/e2e/operating-system-selector.cy.ts index 119852b0..86a59d55 100644 --- a/tests/e2e/operating-system-selector.cy.ts +++ b/tests/e2e/operating-system-selector.cy.ts @@ -2,6 +2,7 @@ import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType'; import { getEnumValues } from '@/application/Common/Enum'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames'; +import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems'; describe('operating system selector', () => { // Regression test for a bug where switching between operating systems caused uncaught exceptions. @@ -29,11 +30,7 @@ function getSupportedOperatingSystemsList() { the application within Cypress, as its compilation is tightly coupled with Vite. Ideally, this should dynamically fetch the list from the compiled application. */ - return [ - OperatingSystem.Windows, - OperatingSystem.Linux, - OperatingSystem.macOS, - ]; + return AllSupportedOperatingSystems; } function assertExpectedActions() { diff --git a/tests/shared/TestCases/SupportedOperatingSystems.ts b/tests/shared/TestCases/SupportedOperatingSystems.ts new file mode 100644 index 00000000..45ed4143 --- /dev/null +++ b/tests/shared/TestCases/SupportedOperatingSystems.ts @@ -0,0 +1,11 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export type SupportedOperatingSystem = OperatingSystem.Windows +| OperatingSystem.Linux +| OperatingSystem.macOS; + +export const AllSupportedOperatingSystems: readonly OperatingSystem[] = [ + OperatingSystem.Windows, + OperatingSystem.Linux, + OperatingSystem.macOS, +] as const; diff --git a/tests/unit/domain/ProjectInformation.spec.ts b/tests/unit/domain/ProjectInformation.spec.ts index 48bd187c..22132186 100644 --- a/tests/unit/domain/ProjectInformation.spec.ts +++ b/tests/unit/domain/ProjectInformation.spec.ts @@ -5,6 +5,7 @@ import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTes import { VersionStub } from '@tests/unit/shared/Stubs/VersionStub'; import { Version } from '@/domain/Version'; import { PropertyKeys } from '@/TypeHelpers'; +import { SupportedOperatingSystem, AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems'; describe('ProjectInformation', () => { describe('retrieval of property values', () => { @@ -117,48 +118,42 @@ describe('ProjectInformation', () => { }); }); }); - describe('correct retrieval of download URL per operating system', () => { - const testCases: ReadonlyArray<{ - readonly os: OperatingSystem, + describe('correct retrieval of download URL for every supported operating system', () => { + const testCases: Record = [ - { - os: OperatingSystem.macOS, + }> = { + [OperatingSystem.macOS]: { expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.dmg', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git', version: '0.7.2', }, - { - os: OperatingSystem.Linux, + [OperatingSystem.Linux]: { expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.AppImage', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git', version: '0.7.2', }, - { - os: OperatingSystem.Windows, + [OperatingSystem.Windows]: { expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-Setup-0.7.2.exe', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git', version: '0.7.2', }, - ]; - for (const testCase of testCases) { - it(`should return the expected download URL for ${OperatingSystem[testCase.os]}`, () => { + }; + AllSupportedOperatingSystems.forEach((operatingSystem) => { + it(`should return the expected download URL for ${OperatingSystem[operatingSystem]}`, () => { // arrange - const { - expected, version, repositoryUrl, os, - } = testCase; + const { expected, version, repositoryUrl } = testCases[operatingSystem]; const sut = new ProjectInformationBuilder() .withVersion(new VersionStub(version)) .withRepositoryUrl(repositoryUrl) .build(); // act - const actual = sut.getDownloadUrl(os); + const actual = sut.getDownloadUrl(operatingSystem); // assert expect(actual).to.equal(expected); }); - } + }); describe('should throw an error when provided with an invalid operating system', () => { // arrange const sut = new ProjectInformationBuilder() diff --git a/tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts b/tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts new file mode 100644 index 00000000..d6def7e0 --- /dev/null +++ b/tests/unit/infrastructure/CodeRunner/FileNameGenerator.spec.ts @@ -0,0 +1,84 @@ +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 { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage'; + +describe('FileNameGenerator', () => { + describe('generateOsTimestampedFileName', () => { + it('generates correct prefix', () => { + // arrange + const expectedPrefix = 'run'; + // act + const fileName = generateFileNamePartsForTesting(); + // assert + expect(fileName.prefix).to.equal(expectedPrefix); + }); + it('generates correct timestamp', () => { + // arrange + const currentDate = '2023-01-01T12:00:00.000Z'; + const expectedTimestamp = '2023-01-01_12-00-00'; + const date = new Date(currentDate); + // act + const fileName = generateFileNamePartsForTesting({ date }); + // assert + expect(fileName.timestamp).to.equal(expectedTimestamp, formatAssertionMessage[ + `Generated file name: ${fileName.generatedFileName}` + ]); + }); + describe('generates correct extension', () => { + const testScenarios: Record = { + [OperatingSystem.Windows]: 'bat', + [OperatingSystem.Linux]: 'sh', + [OperatingSystem.macOS]: 'sh', + }; + AllSupportedOperatingSystems.forEach((operatingSystem) => { + it(`on ${OperatingSystem[operatingSystem]}`, () => { + // arrange + const expectedExtension = testScenarios[operatingSystem]; + // act + const fileName = generateFileNamePartsForTesting({ operatingSystem }); + // assert + expect(fileName.extension).to.equal(expectedExtension, formatAssertionMessage[ + `Generated file name: ${fileName.generatedFileName}` + ]); + }); + }); + }); + it('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(); + }); + }); +}); + +interface TestFileNameComponents { + readonly prefix: string; + readonly timestamp: string; + readonly extension?: string; + readonly generatedFileName: string; +} + +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 pattern = /^(?[^-]+)-(?[^.]+)(?:\.(?[^.]+))?$/; // prefix-timestamp.extension + const match = fileName.match(pattern); + if (!match?.groups?.prefix || !match?.groups?.timestamp) { + throw new Error(`Failed to parse prefix or timestamp: ${fileName}`); + } + return { + 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 274bc005..7938e41b 100644 --- a/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts +++ b/tests/unit/infrastructure/CodeRunner/TemporaryFileCodeRunner.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; -import { TemporaryFileCodeRunner } from '@/infrastructure/CodeRunner/TemporaryFileCodeRunner'; +import { FileNameGenerator, 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'; @@ -9,178 +9,254 @@ 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'; describe('TemporaryFileCodeRunner', () => { describe('runCode', () => { - it('creates temporary directory recursively', async () => { - // arrange - const expectedDir = 'expected-dir'; - const expectedIsRecursive = true; + describe('directory creation', () => { + it('creates temporary directory recursively', async () => { + // arrange + const expectedDir = 'expected-dir'; + const expectedIsRecursive = true; - const folderName = 'privacy.sexy'; - const temporaryDirName = 'tmp'; - const filesystem = new FileSystemOpsStub(); - const context = new TestContext() - .withSystemOperationsStub((ops) => ops - .withOperatingSystem( - new OperatingSystemOpsStub() - .withTemporaryDirectoryResult(temporaryDirName), - ) - .withLocation( - new LocationOpsStub() - .withJoinResult(expectedDir, temporaryDirName, folderName), - ) - .withFileSystem(filesystem)); + const folderName = 'privacy.sexy'; + const temporaryDirName = 'tmp'; + const filesystem = new FileSystemOpsStub(); + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops + .withOperatingSystem( + new OperatingSystemOpsStub() + .withTemporaryDirectoryResult(temporaryDirName), + ) + .withLocation( + new LocationOpsStub() + .withJoinResult(expectedDir, temporaryDirName, folderName), + ) + .withFileSystem(filesystem)); - // act - await context - .withFolderName(folderName) - .runCode(); + // act + await context + .withFolderName(folderName) + .runCode(); - // assert - const calls = filesystem.callHistory.filter((call) => call.methodName === 'createDirectory'); - expect(calls.length).to.equal(1); - const [actualPath, actualIsRecursive] = calls[0].args; - expect(actualPath).to.equal(expectedDir); - expect(actualIsRecursive).to.equal(expectedIsRecursive); + // assert + const calls = filesystem.callHistory.filter((call) => call.methodName === 'createDirectory'); + expect(calls.length).to.equal(1); + const [actualPath, actualIsRecursive] = calls[0].args; + expect(actualPath).to.equal(expectedDir); + expect(actualIsRecursive).to.equal(expectedIsRecursive); + }); }); - it('creates a file with expected code and path', async () => { - // arrange - const expectedCode = 'expected-code'; - const expectedFilePath = 'expected-file-path'; + describe('file creation', () => { + it('creates a file with expected code', async () => { + // arrange + const expectedCode = 'expected-code'; + const filesystem = new FileSystemOpsStub(); + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops + .withFileSystem(filesystem)); + // act + await context + .withCode(expectedCode) + .runCode(); - const filesystem = new FileSystemOpsStub(); - const extension = '.sh'; - const expectedName = `run.${extension}`; - const folderName = 'privacy.sexy'; - const temporaryDirName = 'tmp'; - const context = new TestContext() - .withSystemOperationsStub((ops) => ops - .withOperatingSystem( - new OperatingSystemOpsStub() - .withTemporaryDirectoryResult(temporaryDirName), - ) - .withLocation( - new LocationOpsStub() - .withJoinResult('folder', temporaryDirName, folderName) - .withJoinResult(expectedFilePath, 'folder', expectedName), - ) - .withFileSystem(filesystem)); + // assert + const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); + expect(calls.length).to.equal(1); + 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 - .withCode(expectedCode) - .withFolderName(folderName) - .withExtension(extension) - .runCode(); + // act + await context.runCode(); - // assert - const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); - expect(calls.length).to.equal(1); - const [actualFilePath, actualData] = calls[0].args; - expect(actualFilePath).to.equal(expectedFilePath); - expect(actualData).to.equal(expectedCode); + // 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/'; + const temporaryDirName = '/tmp'; + const folderName = 'privacy.sexy'; + const expectedDirectory = [temporaryDirName, folderName].join(pathSegmentSeparator); + const filesystem = new FileSystemOpsStub(); + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops + .withOperatingSystem( + new OperatingSystemOpsStub() + .withTemporaryDirectoryResult(temporaryDirName), + ) + .withLocation( + new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator), + ) + .withFileSystem(filesystem)); + + // act + await context + .withFolderName(folderName) + .runCode(); + + // assert + const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); + expect(calls.length).to.equal(1); + const [actualFilePath] = calls[0].args; + const actualDirectory = actualFilePath + .split(pathSegmentSeparator) + .slice(0, -1) + .join(pathSegmentSeparator); + expect(actualDirectory).to.equal(expectedDirectory, formatAssertionMessage([ + `Actual file path: ${actualFilePath}`, + ])); + }); + it('creates file with expected file name', async () => { + // arrange + const pathSegmentSeparator = '/PATH-SEGMENT-SEPARATOR/'; + const filesystem = new FileSystemOpsStub(); + const expectedFileName = 'expected-script-file-name'; + const context = new CodeRunnerTestSetup() + .withFileNameGenerator(() => expectedFileName) + .withSystemOperationsStub((ops) => ops + .withFileSystem(filesystem) + .withLocation(new LocationOpsStub().withDefaultSeparator(pathSegmentSeparator))); + + // act + await context.runCode(); + + // assert + const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); + expect(calls.length).to.equal(1); + const [actualFilePath] = calls[0].args; + const actualFileName = actualFilePath + .split(pathSegmentSeparator) + .pop(); + expect(actualFileName).to.equal(actualFileName, formatAssertionMessage([ + `Actual file path: ${actualFilePath}`, + ])); + }); }); - it('set file permissions as expected', async () => { - // arrange - const expectedMode = '755'; - const expectedFilePath = 'expected-file-path'; + 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)); - const filesystem = new FileSystemOpsStub(); - const extension = '.sh'; - const expectedName = `run.${extension}`; - const folderName = 'privacy.sexy'; - const temporaryDirName = 'tmp'; - const context = new TestContext() - .withSystemOperationsStub((ops) => ops - .withOperatingSystem( - new OperatingSystemOpsStub() - .withTemporaryDirectoryResult(temporaryDirName), - ) - .withLocation( - new LocationOpsStub() - .withJoinResult('folder', temporaryDirName, folderName) - .withJoinResult(expectedFilePath, 'folder', expectedName), - ) - .withFileSystem(filesystem)); + // act + await context.runCode(); - // act - await context - .withFolderName(folderName) - .withExtension(extension) - .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)); - // assert - const calls = filesystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); - expect(calls.length).to.equal(1); - const [actualFilePath, actualMode] = calls[0].args; - expect(actualFilePath).to.equal(expectedFilePath); - expect(actualMode).to.equal(expectedMode); + // 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('executes as expected', () => { - // arrange - const filePath = 'expected-file-path'; - interface ExecutionTestCase { - readonly givenOs: OperatingSystem; - readonly expectedCommand: string; - } - const testData: readonly ExecutionTestCase[] = [ - { - givenOs: OperatingSystem.Windows, - expectedCommand: filePath, - }, - { - givenOs: OperatingSystem.macOS, - expectedCommand: `open -a Terminal.app ${filePath}`, - }, - { - givenOs: OperatingSystem.Linux, - expectedCommand: `x-terminal-emulator -e '${filePath}'`, - }, - ]; - for (const { givenOs, expectedCommand } of testData) { - it(`returns ${expectedCommand} on ${OperatingSystem[givenOs]}`, async () => { - const command = new CommandOpsStub(); - const context = new TestContext() - .withSystemOperationsStub((ops) => ops - .withLocation( - new LocationOpsStub() - .withJoinResultSequence('non-important-folder-name', filePath), - ) - .withCommand(command)); + 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(givenOs) - .runCode(); + // 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); + // 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. - const expectedOrder: readonly FunctionKeys[] = [ - 'createDirectory', - 'writeToFile', - 'setFilePermissions', - ]; - const fileSystem = new FileSystemOpsStub(); - const context = new TestContext() - .withSystemOperationsStub((ops) => ops - .withFileSystem(fileSystem)); + }); + it('runs in expected order', async () => { // verifies correct `async`, `await` usage. + const expectedOrder: readonly FunctionKeys[] = [ + 'createDirectory', + 'writeToFile', + 'setFilePermissions', + ]; + const fileSystem = new FileSystemOpsStub(); + const context = new CodeRunnerTestSetup() + .withSystemOperationsStub((ops) => ops + .withFileSystem(fileSystem)); - // act - await context.runCode(); + // act + await context.runCode(); - // assert - const actualOrder = fileSystem.callHistory - .map((c) => c.methodName) - .filter((command) => expectedOrder.includes(command)); - expect(expectedOrder).to.deep.equal(actualOrder); + // assert + const actualOrder = fileSystem.callHistory + .map((c) => c.methodName) + .filter((command) => expectedOrder.includes(command)); + expect(expectedOrder).to.deep.equal(actualOrder); + }); }); describe('throws with invalid OS', () => { const testScenarios: ReadonlyArray<{ @@ -200,7 +276,7 @@ describe('TemporaryFileCodeRunner', () => { testScenarios.forEach(({ description, invalidOs, expectedError }) => { it(description, async () => { // arrange - const context = new TestContext() + const context = new CodeRunnerTestSetup() .withOs(invalidOs); // act const act = async () => { await context.runCode(); }; @@ -212,20 +288,26 @@ describe('TemporaryFileCodeRunner', () => { }); }); -class TestContext { - private code = 'code'; +class CodeRunnerTestSetup { + private code = `[${CodeRunnerTestSetup.name}]code`; - private folderName = 'folderName'; + private folderName = `[${CodeRunnerTestSetup.name}]folderName`; - private fileExtension = 'fileExtension'; + private fileNameGenerator: FileNameGenerator = () => `[${CodeRunnerTestSetup.name}]file-name-stub`; private os: OperatingSystem = OperatingSystem.Windows; private systemOperations: SystemOperations = new SystemOperationsStub(); + private logger: Logger = new LoggerStub(); + public async runCode(): Promise { - const runner = new TemporaryFileCodeRunner(this.systemOperations); - await runner.runCode(this.code, this.folderName, this.fileExtension, this.os); + const runner = new TemporaryFileCodeRunner( + this.systemOperations, + this.fileNameGenerator, + this.logger, + ); + await runner.runCode(this.code, this.folderName, this.os); } public withSystemOperations( @@ -257,8 +339,8 @@ class TestContext { return this; } - public withExtension(fileExtension: string): this { - this.fileExtension = fileExtension; + public withFileNameGenerator(fileNameGenerator: FileNameGenerator): this { + this.fileNameGenerator = fileNameGenerator; return this; } } diff --git a/tests/unit/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory.spec.ts b/tests/unit/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory.spec.ts index da4eb7f7..01eb7bdb 100644 --- a/tests/unit/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory.spec.ts +++ b/tests/unit/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory.spec.ts @@ -3,15 +3,15 @@ import { OperatingSystem } from '@/domain/OperatingSystem'; import { getInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory'; import { getEnumValues } from '@/application/Common/Enum'; import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder'; +import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems'; describe('InstructionListDataFactory', () => { - const supportedOsList = [OperatingSystem.macOS, OperatingSystem.Linux]; describe('getInstructions', () => { it('returns expected if os is supported', () => { // arrange const fileName = 'test.file'; // act - const actualResults = supportedOsList.map((os) => getInstructions(os, fileName)); + const actualResults = AllSupportedOperatingSystems.map((os) => getInstructions(os, fileName)); // assert expect(actualResults.every((result) => result instanceof InstructionsBuilder)); }); @@ -20,7 +20,7 @@ describe('InstructionListDataFactory', () => { const expected = undefined; const fileName = 'test.file'; const unsupportedOses = getEnumValues(OperatingSystem) - .filter((value) => !supportedOsList.includes(value)); + .filter((value) => !AllSupportedOperatingSystems.includes(value)); // act const actualResults = unsupportedOses.map((os) => getInstructions(os, fileName)); // assert diff --git a/tests/unit/shared/Stubs/LocationOpsStub.ts b/tests/unit/shared/Stubs/LocationOpsStub.ts index 5c8b75bf..e1150d26 100644 --- a/tests/unit/shared/Stubs/LocationOpsStub.ts +++ b/tests/unit/shared/Stubs/LocationOpsStub.ts @@ -8,6 +8,8 @@ export class LocationOpsStub private scenarios = new Map(); + private defaultSeparator = `/[${LocationOpsStub.name}]PATH-SEGMENT-SEPARATOR/`; + public withJoinResult(returnValue: string, ...paths: string[]): this { this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue); return this; @@ -19,6 +21,11 @@ export class LocationOpsStub return this; } + public withDefaultSeparator(defaultSeparator: string): this { + this.defaultSeparator = defaultSeparator; + return this; + } + public combinePaths(...pathSegments: string[]): string { this.registerMethodCall({ methodName: 'combinePaths', @@ -33,7 +40,7 @@ export class LocationOpsStub if (foundScenario) { return foundScenario; } - return pathSegments.join('/PATH-SEGMENT-SEPARATOR/'); + return pathSegments.join(this.defaultSeparator); } private static getScenarioKey(paths: string[]): string {