Improve desktop script runs with timestamps & logs

Improve script execution in the desktop app by introducing timestamped
filenames and detailed logging. These changes aim to facilitate easier
debugging, auditing and overall better user experience.

Key changes:

- Add timestamps in filenames for temporary files to aid in
  troubleshooting and auditing.
- Add application logging throughout the script execution process to
  enhance troubleshooting capabilities.

Other supporting changes:

- Refactor `TemporaryFileCodeRunner` with subfunctions for improved
  readability, maintenance, reusability and extensibility.
- Refactor unit tests for `TemporaryFileCodeRunner` for improved
  granularity and simplicity.
- Create centralized definition of supported operating systems by
  privacy.sexy to ensure robust and consistent test case creation.
- Simplify the `runCode` method by removing the file extension
  parameter; now handled internally by `FileNameGenerator`.
This commit is contained in:
undergroundwires
2023-12-31 14:28:58 +01:00
parent 8f4b34f8f1
commit cdc32d1f12
11 changed files with 465 additions and 202 deletions

View File

@@ -2,6 +2,8 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
export interface CodeRunner { export interface CodeRunner {
runCode( runCode(
code: string, folderName: string, fileExtension: string, os: OperatingSystem, code: string,
tempScriptFolderName: string,
os: OperatingSystem,
): Promise<void>; ): Promise<void>;
} }

View File

@@ -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<Record<OperatingSystem, string>> = {
// '.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(/\..+/, '');
}

View File

@@ -1,37 +1,88 @@
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/application/CodeRunner'; import { CodeRunner } from '@/application/CodeRunner';
import { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../Log/ElectronLogger';
import { SystemOperations } from './SystemOperations/SystemOperations'; import { SystemOperations } from './SystemOperations/SystemOperations';
import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations'; import { createNodeSystemOperations } from './SystemOperations/NodeSystemOperations';
import { generateOsTimestampedFileName } from './FileNameGenerator';
export type FileNameGenerator = (os: OperatingSystem) => string;
export class TemporaryFileCodeRunner implements CodeRunner { export class TemporaryFileCodeRunner implements CodeRunner {
constructor( constructor(
private readonly system: SystemOperations = createNodeSystemOperations(), private readonly system: SystemOperations = createNodeSystemOperations(),
private readonly fileNameGenerator: FileNameGenerator = generateOsTimestampedFileName,
private readonly logger: Logger = ElectronLogger,
) { } ) { }
public async runCode( public async runCode(
code: string, code: string,
folderName: string, tempScriptFolderName: string,
fileExtension: string,
os: OperatingSystem, os: OperatingSystem,
): Promise<void> { ): Promise<void> {
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<string> {
const directoryPath = this.system.location.combinePaths(
this.system.operatingSystem.getTempDirectory(), this.system.operatingSystem.getTempDirectory(),
folderName, tempScriptFolderName,
); );
await this.system.fileSystem.createDirectory(dir, true); await this.createDirectoryIfNotExists(directoryPath);
const filePath = this.system.location.combinePaths(dir, `run.${fileExtension}`); const filePath = this.system.location.combinePaths(directoryPath, fileName);
await this.system.fileSystem.writeToFile(filePath, code); await this.createFile(filePath, contents);
await this.system.fileSystem.setFilePermissions(filePath, '755'); return filePath;
}
private async createFile(filePath: string, contents: string): Promise<void> {
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<void> {
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<void> {
await this.setFileExecutablePermissions(filePath);
const command = getExecuteCommand(filePath, os); const command = getExecuteCommand(filePath, os);
await this.executeCommand(command);
}
private async setFileExecutablePermissions(filePath: string): Promise<void> {
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<void> {
this.logger.info(`Executing command: ${command}`);
await this.system.command.execute(command); await this.system.command.execute(command);
this.logger.info('Command executed successfully.');
} }
} }
function getExecuteCommand( function getExecuteCommand(
scriptPath: string, scriptPath: string,
currentOperatingSystem: OperatingSystem, os: OperatingSystem,
): string { ): string {
switch (currentOperatingSystem) { switch (os) {
case OperatingSystem.Linux: case OperatingSystem.Linux:
return `x-terminal-emulator -e '${scriptPath}'`; return `x-terminal-emulator -e '${scriptPath}'`;
case OperatingSystem.macOS: case OperatingSystem.macOS:
@@ -42,6 +93,6 @@ function getExecuteCommand(
case OperatingSystem.Windows: case OperatingSystem.Windows:
return scriptPath; return scriptPath;
default: default:
throw Error(`unsupported os: ${OperatingSystem[currentOperatingSystem]}`); throw Error(`unsupported os: ${OperatingSystem[os]}`);
} }
} }

View File

@@ -30,7 +30,6 @@ export default defineComponent({
await codeRunner.runCode( await codeRunner.runCode(
currentContext.state.code.current, currentContext.state.code.current,
currentContext.app.info.name, currentContext.app.info.name,
currentState.value.collection.scripting.fileExtension,
os, os,
); );
} }

View File

@@ -2,6 +2,7 @@ import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { getEnumValues } from '@/application/Common/Enum'; import { getEnumValues } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames'; import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems';
describe('operating system selector', () => { describe('operating system selector', () => {
// Regression test for a bug where switching between operating systems caused uncaught exceptions. // 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. the application within Cypress, as its compilation is tightly coupled with Vite.
Ideally, this should dynamically fetch the list from the compiled application. Ideally, this should dynamically fetch the list from the compiled application.
*/ */
return [ return AllSupportedOperatingSystems;
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
} }
function assertExpectedActions() { function assertExpectedActions() {

View File

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

View File

@@ -5,6 +5,7 @@ import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTes
import { VersionStub } from '@tests/unit/shared/Stubs/VersionStub'; import { VersionStub } from '@tests/unit/shared/Stubs/VersionStub';
import { Version } from '@/domain/Version'; import { Version } from '@/domain/Version';
import { PropertyKeys } from '@/TypeHelpers'; import { PropertyKeys } from '@/TypeHelpers';
import { SupportedOperatingSystem, AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems';
describe('ProjectInformation', () => { describe('ProjectInformation', () => {
describe('retrieval of property values', () => { describe('retrieval of property values', () => {
@@ -117,48 +118,42 @@ describe('ProjectInformation', () => {
}); });
}); });
}); });
describe('correct retrieval of download URL per operating system', () => { describe('correct retrieval of download URL for every supported operating system', () => {
const testCases: ReadonlyArray<{ const testCases: Record<SupportedOperatingSystem, {
readonly os: OperatingSystem,
readonly expected: string, readonly expected: string,
readonly repositoryUrl: string, readonly repositoryUrl: string,
readonly version: string, readonly version: string,
}> = [ }> = {
{ [OperatingSystem.macOS]: {
os: OperatingSystem.macOS,
expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.dmg', 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', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git',
version: '0.7.2', version: '0.7.2',
}, },
{ [OperatingSystem.Linux]: {
os: OperatingSystem.Linux,
expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-0.7.2.AppImage', 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', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git',
version: '0.7.2', version: '0.7.2',
}, },
{ [OperatingSystem.Windows]: {
os: OperatingSystem.Windows,
expected: 'https://github.com/undergroundwires/privacy.sexy/releases/download/0.7.2/privacy.sexy-Setup-0.7.2.exe', 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', repositoryUrl: 'https://github.com/undergroundwires/privacy.sexy.git',
version: '0.7.2', version: '0.7.2',
}, },
]; };
for (const testCase of testCases) { AllSupportedOperatingSystems.forEach((operatingSystem) => {
it(`should return the expected download URL for ${OperatingSystem[testCase.os]}`, () => { it(`should return the expected download URL for ${OperatingSystem[operatingSystem]}`, () => {
// arrange // arrange
const { const { expected, version, repositoryUrl } = testCases[operatingSystem];
expected, version, repositoryUrl, os,
} = testCase;
const sut = new ProjectInformationBuilder() const sut = new ProjectInformationBuilder()
.withVersion(new VersionStub(version)) .withVersion(new VersionStub(version))
.withRepositoryUrl(repositoryUrl) .withRepositoryUrl(repositoryUrl)
.build(); .build();
// act // act
const actual = sut.getDownloadUrl(os); const actual = sut.getDownloadUrl(operatingSystem);
// assert // assert
expect(actual).to.equal(expected); expect(actual).to.equal(expected);
}); });
} });
describe('should throw an error when provided with an invalid operating system', () => { describe('should throw an error when provided with an invalid operating system', () => {
// arrange // arrange
const sut = new ProjectInformationBuilder() const sut = new ProjectInformationBuilder()

View File

@@ -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<SupportedOperatingSystem, string> = {
[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>[^.]+))?$/; // 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,
};
}

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { FileSystemOps, SystemOperations } from '@/infrastructure/CodeRunner/SystemOperations/SystemOperations'; 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 { expectThrowsAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub'; import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub';
@@ -9,9 +9,14 @@ import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub';
import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub'; import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub';
import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub'; import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub';
import { FunctionKeys } from '@/TypeHelpers'; 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('TemporaryFileCodeRunner', () => {
describe('runCode', () => { describe('runCode', () => {
describe('directory creation', () => {
it('creates temporary directory recursively', async () => { it('creates temporary directory recursively', async () => {
// arrange // arrange
const expectedDir = 'expected-dir'; const expectedDir = 'expected-dir';
@@ -20,7 +25,7 @@ describe('TemporaryFileCodeRunner', () => {
const folderName = 'privacy.sexy'; const folderName = 'privacy.sexy';
const temporaryDirName = 'tmp'; const temporaryDirName = 'tmp';
const filesystem = new FileSystemOpsStub(); const filesystem = new FileSystemOpsStub();
const context = new TestContext() const context = new CodeRunnerTestSetup()
.withSystemOperationsStub((ops) => ops .withSystemOperationsStub((ops) => ops
.withOperatingSystem( .withOperatingSystem(
new OperatingSystemOpsStub() new OperatingSystemOpsStub()
@@ -44,54 +49,133 @@ describe('TemporaryFileCodeRunner', () => {
expect(actualPath).to.equal(expectedDir); expect(actualPath).to.equal(expectedDir);
expect(actualIsRecursive).to.equal(expectedIsRecursive); expect(actualIsRecursive).to.equal(expectedIsRecursive);
}); });
it('creates a file with expected code and path', async () => { });
describe('file creation', () => {
it('creates a file with expected code', async () => {
// arrange // arrange
const expectedCode = 'expected-code'; const expectedCode = 'expected-code';
const expectedFilePath = 'expected-file-path';
const filesystem = new FileSystemOpsStub(); const filesystem = new FileSystemOpsStub();
const extension = '.sh'; const context = new CodeRunnerTestSetup()
const expectedName = `run.${extension}`;
const folderName = 'privacy.sexy';
const temporaryDirName = 'tmp';
const context = new TestContext()
.withSystemOperationsStub((ops) => ops .withSystemOperationsStub((ops) => ops
.withOperatingSystem(
new OperatingSystemOpsStub()
.withTemporaryDirectoryResult(temporaryDirName),
)
.withLocation(
new LocationOpsStub()
.withJoinResult('folder', temporaryDirName, folderName)
.withJoinResult(expectedFilePath, 'folder', expectedName),
)
.withFileSystem(filesystem)); .withFileSystem(filesystem));
// act // act
await context await context
.withCode(expectedCode) .withCode(expectedCode)
.withFolderName(folderName)
.withExtension(extension)
.runCode(); .runCode();
// assert // assert
const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile'); const calls = filesystem.callHistory.filter((call) => call.methodName === 'writeToFile');
expect(calls.length).to.equal(1); expect(calls.length).to.equal(1);
const [actualFilePath, actualData] = calls[0].args; const [, actualData] = calls[0].args;
expect(actualFilePath).to.equal(expectedFilePath);
expect(actualData).to.equal(expectedCode); expect(actualData).to.equal(expectedCode);
}); });
it('set file permissions as expected', async () => { it('generates file name for correct operating system', async () => {
// arrange
const expectedOperatingSystem = OperatingSystem.macOS;
const calls = new Array<OperatingSystem>();
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/';
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}`,
]));
});
});
describe('file permissions', () => {
it('sets correct permissions', async () => {
// arrange // arrange
const expectedMode = '755'; const expectedMode = '755';
const expectedFilePath = 'expected-file-path'; 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 filesystem = new FileSystemOpsStub();
const extension = '.sh'; const extension = '.sh';
const expectedName = `run.${extension}`; const expectedName = `run.${extension}`;
const folderName = 'privacy.sexy'; const folderName = 'privacy.sexy';
const temporaryDirName = 'tmp'; const temporaryDirName = 'tmp';
const context = new TestContext() const context = new CodeRunnerTestSetup()
.withSystemOperationsStub((ops) => ops .withSystemOperationsStub((ops) => ops
.withOperatingSystem( .withOperatingSystem(
new OperatingSystemOpsStub() new OperatingSystemOpsStub()
@@ -107,41 +191,32 @@ describe('TemporaryFileCodeRunner', () => {
// act // act
await context await context
.withFolderName(folderName) .withFolderName(folderName)
.withExtension(extension) .withFileNameGenerator(() => expectedName)
.runCode(); .runCode();
// assert // assert
const calls = filesystem.callHistory.filter((call) => call.methodName === 'setFilePermissions'); const calls = filesystem.callHistory.filter((call) => call.methodName === 'setFilePermissions');
expect(calls.length).to.equal(1); expect(calls.length).to.equal(1);
const [actualFilePath, actualMode] = calls[0].args; const [actualFilePath] = calls[0].args;
expect(actualFilePath).to.equal(expectedFilePath); expect(actualFilePath).to.equal(expectedFilePath);
expect(actualMode).to.equal(expectedMode);
}); });
describe('executes as expected', () => { });
describe('file execution', () => {
describe('executes correct command', () => {
// arrange // arrange
const filePath = 'expected-file-path'; const filePath = 'executed-file-path';
interface ExecutionTestCase { const testScenarios: Record<SupportedOperatingSystem, string> = {
readonly givenOs: OperatingSystem; [OperatingSystem.Windows]: filePath,
readonly expectedCommand: string; [OperatingSystem.macOS]: `open -a Terminal.app ${filePath}`,
} [OperatingSystem.Linux]: `x-terminal-emulator -e '${filePath}'`,
const testData: readonly ExecutionTestCase[] = [ };
{ AllSupportedOperatingSystems.forEach((operatingSystem) => {
givenOs: OperatingSystem.Windows, it(`returns correctly for ${OperatingSystem[operatingSystem]}`, async () => {
expectedCommand: filePath, // arrange
}, const expectedCommand = testScenarios[operatingSystem];
{
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 command = new CommandOpsStub();
const context = new TestContext() const context = new CodeRunnerTestSetup()
.withFileNameGenerator(() => filePath)
.withSystemOperationsStub((ops) => ops .withSystemOperationsStub((ops) => ops
.withLocation( .withLocation(
new LocationOpsStub() new LocationOpsStub()
@@ -151,7 +226,7 @@ describe('TemporaryFileCodeRunner', () => {
// act // act
await context await context
.withOs(givenOs) .withOs(operatingSystem)
.runCode(); .runCode();
// assert // assert
@@ -160,7 +235,7 @@ describe('TemporaryFileCodeRunner', () => {
const [actualCommand] = calls[0].args; const [actualCommand] = calls[0].args;
expect(actualCommand).to.equal(expectedCommand); expect(actualCommand).to.equal(expectedCommand);
}); });
} });
}); });
it('runs in expected order', async () => { // verifies correct `async`, `await` usage. it('runs in expected order', async () => { // verifies correct `async`, `await` usage.
const expectedOrder: readonly FunctionKeys<FileSystemOps>[] = [ const expectedOrder: readonly FunctionKeys<FileSystemOps>[] = [
@@ -169,7 +244,7 @@ describe('TemporaryFileCodeRunner', () => {
'setFilePermissions', 'setFilePermissions',
]; ];
const fileSystem = new FileSystemOpsStub(); const fileSystem = new FileSystemOpsStub();
const context = new TestContext() const context = new CodeRunnerTestSetup()
.withSystemOperationsStub((ops) => ops .withSystemOperationsStub((ops) => ops
.withFileSystem(fileSystem)); .withFileSystem(fileSystem));
@@ -182,6 +257,7 @@ describe('TemporaryFileCodeRunner', () => {
.filter((command) => expectedOrder.includes(command)); .filter((command) => expectedOrder.includes(command));
expect(expectedOrder).to.deep.equal(actualOrder); expect(expectedOrder).to.deep.equal(actualOrder);
}); });
});
describe('throws with invalid OS', () => { describe('throws with invalid OS', () => {
const testScenarios: ReadonlyArray<{ const testScenarios: ReadonlyArray<{
readonly description: string; readonly description: string;
@@ -200,7 +276,7 @@ describe('TemporaryFileCodeRunner', () => {
testScenarios.forEach(({ description, invalidOs, expectedError }) => { testScenarios.forEach(({ description, invalidOs, expectedError }) => {
it(description, async () => { it(description, async () => {
// arrange // arrange
const context = new TestContext() const context = new CodeRunnerTestSetup()
.withOs(invalidOs); .withOs(invalidOs);
// act // act
const act = async () => { await context.runCode(); }; const act = async () => { await context.runCode(); };
@@ -212,20 +288,26 @@ describe('TemporaryFileCodeRunner', () => {
}); });
}); });
class TestContext { class CodeRunnerTestSetup {
private code = 'code'; 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 os: OperatingSystem = OperatingSystem.Windows;
private systemOperations: SystemOperations = new SystemOperationsStub(); private systemOperations: SystemOperations = new SystemOperationsStub();
private logger: Logger = new LoggerStub();
public async runCode(): Promise<void> { public async runCode(): Promise<void> {
const runner = new TemporaryFileCodeRunner(this.systemOperations); const runner = new TemporaryFileCodeRunner(
await runner.runCode(this.code, this.folderName, this.fileExtension, this.os); this.systemOperations,
this.fileNameGenerator,
this.logger,
);
await runner.runCode(this.code, this.folderName, this.os);
} }
public withSystemOperations( public withSystemOperations(
@@ -257,8 +339,8 @@ class TestContext {
return this; return this;
} }
public withExtension(fileExtension: string): this { public withFileNameGenerator(fileNameGenerator: FileNameGenerator): this {
this.fileExtension = fileExtension; this.fileNameGenerator = fileNameGenerator;
return this; return this;
} }
} }

View File

@@ -3,15 +3,15 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { getInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory'; import { getInstructions } from '@/presentation/components/Code/CodeButtons/Save/Instructions/InstructionListDataFactory';
import { getEnumValues } from '@/application/Common/Enum'; import { getEnumValues } from '@/application/Common/Enum';
import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder'; import { InstructionsBuilder } from '@/presentation/components/Code/CodeButtons/Save/Instructions/Data/InstructionsBuilder';
import { AllSupportedOperatingSystems } from '@tests/shared/TestCases/SupportedOperatingSystems';
describe('InstructionListDataFactory', () => { describe('InstructionListDataFactory', () => {
const supportedOsList = [OperatingSystem.macOS, OperatingSystem.Linux];
describe('getInstructions', () => { describe('getInstructions', () => {
it('returns expected if os is supported', () => { it('returns expected if os is supported', () => {
// arrange // arrange
const fileName = 'test.file'; const fileName = 'test.file';
// act // act
const actualResults = supportedOsList.map((os) => getInstructions(os, fileName)); const actualResults = AllSupportedOperatingSystems.map((os) => getInstructions(os, fileName));
// assert // assert
expect(actualResults.every((result) => result instanceof InstructionsBuilder)); expect(actualResults.every((result) => result instanceof InstructionsBuilder));
}); });
@@ -20,7 +20,7 @@ describe('InstructionListDataFactory', () => {
const expected = undefined; const expected = undefined;
const fileName = 'test.file'; const fileName = 'test.file';
const unsupportedOses = getEnumValues(OperatingSystem) const unsupportedOses = getEnumValues(OperatingSystem)
.filter((value) => !supportedOsList.includes(value)); .filter((value) => !AllSupportedOperatingSystems.includes(value));
// act // act
const actualResults = unsupportedOses.map((os) => getInstructions(os, fileName)); const actualResults = unsupportedOses.map((os) => getInstructions(os, fileName));
// assert // assert

View File

@@ -8,6 +8,8 @@ export class LocationOpsStub
private scenarios = new Map<string, string>(); private scenarios = new Map<string, string>();
private defaultSeparator = `/[${LocationOpsStub.name}]PATH-SEGMENT-SEPARATOR/`;
public withJoinResult(returnValue: string, ...paths: string[]): this { public withJoinResult(returnValue: string, ...paths: string[]): this {
this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue); this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue);
return this; return this;
@@ -19,6 +21,11 @@ export class LocationOpsStub
return this; return this;
} }
public withDefaultSeparator(defaultSeparator: string): this {
this.defaultSeparator = defaultSeparator;
return this;
}
public combinePaths(...pathSegments: string[]): string { public combinePaths(...pathSegments: string[]): string {
this.registerMethodCall({ this.registerMethodCall({
methodName: 'combinePaths', methodName: 'combinePaths',
@@ -33,7 +40,7 @@ export class LocationOpsStub
if (foundScenario) { if (foundScenario) {
return foundScenario; return foundScenario;
} }
return pathSegments.join('/PATH-SEGMENT-SEPARATOR/'); return pathSegments.join(this.defaultSeparator);
} }
private static getScenarioKey(paths: string[]): string { private static getScenarioKey(paths: string[]): string {