diff --git a/.github/workflows/checks.desktop-runtime-errors.yaml b/.github/workflows/checks.desktop-runtime-errors.yaml index c662152b..f5917f43 100644 --- a/.github/workflows/checks.desktop-runtime-errors.yaml +++ b/.github/workflows/checks.desktop-runtime-errors.yaml @@ -57,7 +57,7 @@ jobs: - name: Test shell: bash - run: node scripts/check-desktop-runtime-errors --screenshot + run: node ./scripts/check-desktop-runtime-errors --screenshot - name: Upload screenshot if: always() # Run even if previous step fails diff --git a/docs/architecture.md b/docs/architecture.md index 21ca666e..17fdf161 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,11 +15,23 @@ Application is Application uses highly decoupled models & services in different DDD layers: -- presentation layer (see [presentation.md](./presentation.md)), -- application layer (see [application.md](./application.md)), -- and domain layer. +**Application layer** (see [application.md](./application.md)): -Application layer depends on and consumes domain layer. [Presentation layer](./presentation.md) consumes and depends on application layer along with domain layer. Application and presentation layers can communicate through domain model. +- Coordinates application activities and consumes the domain layer. + +**Presentation layer** (see [presentation.md](./presentation.md)): + +- Handles UI/UX, consumes both the application and domain layers. +- May communicate directly with the infrastructure layer for technical needs, but avoids domain logic. + +**Domain layer**: + +- Serves as the system's core and central truth. +- Facilitates communication between the application and presentation layers through the domain model. + +**Infrastructure layer**: + +- Manages technical implementations without dependencies on other layers or domain knowledge. ![DDD + vue.js](./../img/architecture/app-ddd.png) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index bf77aecc..5f6e742f 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -29,7 +29,6 @@ export default defineConfig({ input: { index: WEB_INDEX_HTML_PATH, }, - external: ['os', 'child_process', 'fs', 'path'], }, }, }, diff --git a/src/TypeHelpers.ts b/src/TypeHelpers.ts index cfac3f44..5bed9b42 100644 --- a/src/TypeHelpers.ts +++ b/src/TypeHelpers.ts @@ -9,3 +9,7 @@ export type PropertyKeys = { export type ConstructorArguments = T extends new (...args: infer U) => unknown ? U : never; + +export type FunctionKeys = { + [K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never; +}[keyof T]; diff --git a/src/application/Context/ApplicationContextFactory.ts b/src/application/Context/ApplicationContextFactory.ts index 628b1b1a..cb3e61a7 100644 --- a/src/application/Context/ApplicationContextFactory.ts +++ b/src/application/Context/ApplicationContextFactory.ts @@ -1,8 +1,8 @@ import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { IApplication } from '@/domain/IApplication'; -import { Environment } from '../Environment/Environment'; -import { IEnvironment } from '../Environment/IEnvironment'; +import { Environment } from '@/infrastructure/Environment/Environment'; +import { IEnvironment } from '@/infrastructure/Environment/IEnvironment'; import { IApplicationFactory } from '../IApplicationFactory'; import { ApplicationFactory } from '../ApplicationFactory'; import { ApplicationContext } from './ApplicationContext'; diff --git a/src/application/Environment/Environment.ts b/src/application/Environment/Environment.ts deleted file mode 100644 index cb2cf011..00000000 --- a/src/application/Environment/Environment.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; -import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector'; -import { IEnvironment } from './IEnvironment'; - -export interface IEnvironmentVariables { - readonly window: Window & typeof globalThis; - readonly process: NodeJS.Process; - readonly navigator: Navigator; -} - -export class Environment implements IEnvironment { - public static readonly CurrentEnvironment: IEnvironment = new Environment({ - window, - process: typeof process !== 'undefined' ? process /* electron only */ : undefined, - navigator, - }); - - public readonly isDesktop: boolean; - - public readonly os: OperatingSystem; - - protected constructor( - variables: IEnvironmentVariables, - browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(), - ) { - if (!variables) { - throw new Error('variables is null or empty'); - } - this.isDesktop = isDesktop(variables); - if (this.isDesktop) { - this.os = getDesktopOsType(getProcessPlatform(variables)); - } else { - const userAgent = getUserAgent(variables); - this.os = !userAgent ? undefined : browserOsDetector.detect(userAgent); - } - } -} - -function getUserAgent(variables: IEnvironmentVariables): string { - if (!variables.window || !variables.window.navigator) { - return undefined; - } - return variables.window.navigator.userAgent; -} - -function getProcessPlatform(variables: IEnvironmentVariables): string { - if (!variables.process || !variables.process.platform) { - return undefined; - } - return variables.process.platform; -} - -function getDesktopOsType(processPlatform: string): OperatingSystem | undefined { - // https://nodejs.org/api/process.html#process_process_platform - switch (processPlatform) { - case 'darwin': - return OperatingSystem.macOS; - case 'win32': - return OperatingSystem.Windows; - case 'linux': - return OperatingSystem.Linux; - default: - return undefined; - } -} - -function isDesktop(variables: IEnvironmentVariables): boolean { - // More: https://github.com/electron/electron/issues/2288 - // Renderer process - if (variables.window - && variables.window.process - && variables.window.process.type === 'renderer') { - return true; - } - // Main process - if (variables.process - && variables.process.versions - && Boolean(variables.process.versions.electron)) { - return true; - } - // Detect the user agent when the `nodeIntegration` option is set to true - if (variables.navigator - && variables.navigator.userAgent - && variables.navigator.userAgent.includes('Electron')) { - return true; - } - return false; -} diff --git a/src/application/Environment/IEnvironment.ts b/src/application/Environment/IEnvironment.ts deleted file mode 100644 index 1bec8d1b..00000000 --- a/src/application/Environment/IEnvironment.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - -export interface IEnvironment { - readonly isDesktop: boolean; - readonly os: OperatingSystem; -} diff --git a/src/application/Parser/ApplicationParser.ts b/src/application/Parser/ApplicationParser.ts index 53884ca5..30dc17ad 100644 --- a/src/application/Parser/ApplicationParser.ts +++ b/src/application/Parser/ApplicationParser.ts @@ -14,7 +14,7 @@ import { parseCategoryCollection } from './CategoryCollectionParser'; export function parseApplication( categoryParser = parseCategoryCollection, informationParser = parseProjectInformation, - metadata: IAppMetadata = AppMetadataFactory.Current, + metadata: IAppMetadata = AppMetadataFactory.Current.instance, collectionsData = PreParsedCollections, ): IApplication { validateCollectionsData(collectionsData); diff --git a/src/application/Parser/ProjectInformationParser.ts b/src/application/Parser/ProjectInformationParser.ts index 133bcae6..b6582175 100644 --- a/src/application/Parser/ProjectInformationParser.ts +++ b/src/application/Parser/ProjectInformationParser.ts @@ -7,7 +7,7 @@ import { ConstructorArguments } from '@/TypeHelpers'; export function parseProjectInformation( - metadata: IAppMetadata = AppMetadataFactory.Current, + metadata: IAppMetadata = AppMetadataFactory.Current.instance, createProjectInformation: ProjectInformationFactory = ( ...args ) => new ProjectInformation(...args), diff --git a/src/infrastructure/CodeRunner.ts b/src/infrastructure/CodeRunner.ts index f7aa3969..317758eb 100644 --- a/src/infrastructure/CodeRunner.ts +++ b/src/infrastructure/CodeRunner.ts @@ -1,25 +1,27 @@ -import os from 'os'; -import path from 'path'; -import fs from 'fs'; -import child_process from 'child_process'; -import { Environment } from '@/application/Environment/Environment'; +import { Environment } from '@/infrastructure/Environment/Environment'; import { OperatingSystem } from '@/domain/OperatingSystem'; export class CodeRunner { constructor( - private readonly node = getNodeJs(), private readonly environment = Environment.CurrentEnvironment, ) { + if (!environment.system) { + throw new Error('missing system operations'); + } } public async runCode(code: string, folderName: string, fileExtension: string): Promise { - const dir = this.node.path.join(this.node.os.tmpdir(), folderName); - await this.node.fs.promises.mkdir(dir, { recursive: true }); - const filePath = this.node.path.join(dir, `run.${fileExtension}`); - await this.node.fs.promises.writeFile(filePath, code); - await this.node.fs.promises.chmod(filePath, '755'); + const { system } = this.environment; + const dir = system.location.combinePaths( + system.operatingSystem.getTempDirectory(), + folderName, + ); + await system.fileSystem.createDirectory(dir, true); + const filePath = system.location.combinePaths(dir, `run.${fileExtension}`); + await system.fileSystem.writeToFile(filePath, code); + await system.fileSystem.setFilePermissions(filePath, '755'); const command = getExecuteCommand(filePath, this.environment); - this.node.child_process.exec(command); + system.command.execute(command); } } @@ -38,43 +40,3 @@ function getExecuteCommand(scriptPath: string, environment: Environment): string throw Error(`unsupported os: ${OperatingSystem[environment.os]}`); } } - -function getNodeJs(): INodeJs { - return { - os, path, fs, child_process, - }; -} - -export interface INodeJs { - os: INodeOs; - path: INodePath; - fs: INodeFs; - // eslint-disable-next-line camelcase - child_process: INodeChildProcess; -} - -export interface INodeOs { - tmpdir(): string; -} - -export interface INodePath { - join(...paths: string[]): string; -} - -export interface INodeChildProcess { - exec(command: string): void; -} - -export interface INodeFs { - readonly promises: INodeFsPromises; -} - -interface INodeFsPromisesMakeDirectoryOptions { - recursive?: boolean; -} - -interface INodeFsPromises { // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v13/fs.d.ts - chmod(path: string, mode: string | number): Promise; - mkdir(path: string, options: INodeFsPromisesMakeDirectoryOptions): Promise; - writeFile(path: string, data: string): Promise; -} diff --git a/src/application/Environment/BrowserOs/BrowserOsDetector.ts b/src/infrastructure/Environment/BrowserOs/BrowserOsDetector.ts similarity index 100% rename from src/application/Environment/BrowserOs/BrowserOsDetector.ts rename to src/infrastructure/Environment/BrowserOs/BrowserOsDetector.ts diff --git a/src/application/Environment/BrowserOs/DetectorBuilder.ts b/src/infrastructure/Environment/BrowserOs/DetectorBuilder.ts similarity index 100% rename from src/application/Environment/BrowserOs/DetectorBuilder.ts rename to src/infrastructure/Environment/BrowserOs/DetectorBuilder.ts diff --git a/src/application/Environment/BrowserOs/IBrowserOsDetector.ts b/src/infrastructure/Environment/BrowserOs/IBrowserOsDetector.ts similarity index 100% rename from src/application/Environment/BrowserOs/IBrowserOsDetector.ts rename to src/infrastructure/Environment/BrowserOs/IBrowserOsDetector.ts diff --git a/src/infrastructure/Environment/Environment.ts b/src/infrastructure/Environment/Environment.ts new file mode 100644 index 00000000..cb059854 --- /dev/null +++ b/src/infrastructure/Environment/Environment.ts @@ -0,0 +1,49 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector'; +import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector'; +import { IEnvironment } from './IEnvironment'; +import { WindowVariables } from './WindowVariables'; +import { validateWindowVariables } from './WindowVariablesValidator'; + +export class Environment implements IEnvironment { + public static readonly CurrentEnvironment: IEnvironment = new Environment(window); + + public readonly isDesktop: boolean; + + public readonly os: OperatingSystem | undefined; + + public readonly system: ISystemOperations | undefined; + + protected constructor( + window: Partial, + browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(), + windowValidator: WindowValidator = validateWindowVariables, + ) { + if (!window) { + throw new Error('missing window'); + } + windowValidator(window); + this.isDesktop = isDesktop(window); + if (this.isDesktop) { + this.os = window?.os; + } else { + this.os = undefined; + const userAgent = getUserAgent(window); + if (userAgent) { + this.os = browserOsDetector.detect(userAgent); + } + } + this.system = window?.system; + } +} + +function getUserAgent(window: Partial): string { + return window?.navigator?.userAgent; +} + +function isDesktop(window: Partial): boolean { + return window?.isDesktop === true; +} + +export type WindowValidator = typeof validateWindowVariables; diff --git a/src/infrastructure/Environment/IEnvironment.ts b/src/infrastructure/Environment/IEnvironment.ts new file mode 100644 index 00000000..32f59841 --- /dev/null +++ b/src/infrastructure/Environment/IEnvironment.ts @@ -0,0 +1,8 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; + +export interface IEnvironment { + readonly isDesktop: boolean; + readonly os: OperatingSystem | undefined; + readonly system: ISystemOperations | undefined; +} diff --git a/src/infrastructure/Environment/SystemOperations/ISystemOperations.ts b/src/infrastructure/Environment/SystemOperations/ISystemOperations.ts new file mode 100644 index 00000000..217da587 --- /dev/null +++ b/src/infrastructure/Environment/SystemOperations/ISystemOperations.ts @@ -0,0 +1,24 @@ +export interface ISystemOperations { + readonly operatingSystem: IOperatingSystemOps; + readonly location: ILocationOps; + readonly fileSystem: IFileSystemOps; + readonly command: ICommandOps; +} + +export interface IOperatingSystemOps { + getTempDirectory(): string; +} + +export interface ILocationOps { + combinePaths(...pathSegments: string[]): string; +} + +export interface ICommandOps { + execute(command: string): void; +} + +export interface IFileSystemOps { + setFilePermissions(filePath: string, mode: string | number): Promise; + createDirectory(directoryPath: string, isRecursive?: boolean): Promise; + writeToFile(filePath: string, data: string): Promise; +} diff --git a/src/infrastructure/Environment/SystemOperations/NodeSystemOperations.ts b/src/infrastructure/Environment/SystemOperations/NodeSystemOperations.ts new file mode 100644 index 00000000..3377fb3b --- /dev/null +++ b/src/infrastructure/Environment/SystemOperations/NodeSystemOperations.ts @@ -0,0 +1,33 @@ +import { tmpdir } from 'os'; +import { join } from 'path'; +import { chmod, mkdir, writeFile } from 'fs/promises'; +import { exec } from 'child_process'; +import { ISystemOperations } from './ISystemOperations'; + +export function createNodeSystemOperations(): ISystemOperations { + return { + operatingSystem: { + getTempDirectory: () => tmpdir(), + }, + location: { + combinePaths: (...pathSegments) => join(...pathSegments), + }, + fileSystem: { + setFilePermissions: ( + filePath: string, + mode: string | number, + ) => chmod(filePath, mode), + createDirectory: ( + directoryPath: string, + isRecursive?: boolean, + ) => mkdir(directoryPath, { recursive: isRecursive }), + writeToFile: ( + filePath: string, + data: string, + ) => writeFile(filePath, data), + }, + command: { + execute: (command) => exec(command), + }, + }; +} diff --git a/src/infrastructure/Environment/WindowVariables.ts b/src/infrastructure/Environment/WindowVariables.ts new file mode 100644 index 00000000..36ad4523 --- /dev/null +++ b/src/infrastructure/Environment/WindowVariables.ts @@ -0,0 +1,13 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ISystemOperations } from './SystemOperations/ISystemOperations'; + +export type WindowVariables = { + system: ISystemOperations; + isDesktop: boolean; + os: OperatingSystem; +}; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Window extends WindowVariables { } +} diff --git a/src/infrastructure/Environment/WindowVariablesValidator.ts b/src/infrastructure/Environment/WindowVariablesValidator.ts new file mode 100644 index 00000000..0241f451 --- /dev/null +++ b/src/infrastructure/Environment/WindowVariablesValidator.ts @@ -0,0 +1,76 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { PropertyKeys } from '@/TypeHelpers'; +import { WindowVariables } from './WindowVariables'; + +/** + * Checks for consistency in runtime environment properties injected by Electron preloader. + */ +export function validateWindowVariables(variables: Partial) { + if (!variables) { + throw new Error('missing variables'); + } + if (!isObject(variables)) { + throw new Error(`window is not an object but ${typeof variables}`); + } + const errors = [...testEveryProperty(variables)]; + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } +} + +function* testEveryProperty(variables: Partial): Iterable { + const tests: { + [K in PropertyKeys]: boolean; + } = { + os: testOperatingSystem(variables.os), + isDesktop: testIsDesktop(variables.isDesktop), + system: testSystem(variables), + }; + + for (const [propertyName, testResult] of Object.entries(tests)) { + if (!testResult) { + const propertyValue = variables[propertyName as keyof WindowVariables]; + yield `Unexpected ${propertyName} (${typeof propertyValue})`; + } + } +} + +function testOperatingSystem(os: unknown): boolean { + if (os === undefined) { + return true; + } + if (!isNumber(os)) { + return false; + } + return Object + .values(OperatingSystem) + .includes(os); +} + +function testSystem(variables: Partial): boolean { + if (!variables.isDesktop) { + return true; + } + return variables.system !== undefined && isObject(variables.system); +} + +function testIsDesktop(isDesktop: unknown): boolean { + if (isDesktop === undefined) { + return true; + } + return isBoolean(isDesktop); +} + +function isNumber(variable: unknown): variable is number { + return typeof variable === 'number'; +} + +function isBoolean(variable: unknown): variable is boolean { + return typeof variable === 'boolean'; +} + +function isObject(variable: unknown): variable is object { + return typeof variable === 'object' + && variable !== null // the data type of null is an object + && !Array.isArray(variable); +} diff --git a/src/infrastructure/Metadata/AppMetadataFactory.ts b/src/infrastructure/Metadata/AppMetadataFactory.ts index 70b088fb..e303c788 100644 --- a/src/infrastructure/Metadata/AppMetadataFactory.ts +++ b/src/infrastructure/Metadata/AppMetadataFactory.ts @@ -1,16 +1,18 @@ import { IAppMetadata } from './IAppMetadata'; +import { IAppMetadataFactory } from './IAppMetadataFactory'; +import { validateMetadata } from './MetadataValidator'; import { ViteAppMetadata } from './Vite/ViteAppMetadata'; -export class AppMetadataFactory { - public static get Current(): IAppMetadata { - if (!this.instance) { - this.instance = new ViteAppMetadata(); - } - return this.instance; +export class AppMetadataFactory implements IAppMetadataFactory { + public static readonly Current = new AppMetadataFactory(); + + public readonly instance: IAppMetadata; + + protected constructor(validator: MetadataValidator = validateMetadata) { + const metadata = new ViteAppMetadata(); + validator(metadata); + this.instance = metadata; } - - private static instance: IAppMetadata; - - // eslint-disable-next-line @typescript-eslint/no-empty-function - private constructor() {} } + +export type MetadataValidator = typeof validateMetadata; diff --git a/src/infrastructure/Metadata/IAppMetadataFactory.ts b/src/infrastructure/Metadata/IAppMetadataFactory.ts new file mode 100644 index 00000000..c8caab3a --- /dev/null +++ b/src/infrastructure/Metadata/IAppMetadataFactory.ts @@ -0,0 +1,5 @@ +import { IAppMetadata } from './IAppMetadata'; + +export interface IAppMetadataFactory { + readonly instance: IAppMetadata; +} diff --git a/src/infrastructure/Metadata/MetadataValidator.ts b/src/infrastructure/Metadata/MetadataValidator.ts new file mode 100644 index 00000000..d050c4da --- /dev/null +++ b/src/infrastructure/Metadata/MetadataValidator.ts @@ -0,0 +1,50 @@ +import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; + +/* Validation is externalized to keep the environment objects simple */ +export function validateMetadata(metadata: IAppMetadata): void { + if (!metadata) { + throw new Error('missing metadata'); + } + const keyValues = capturePropertyValues(metadata); + if (!Object.keys(keyValues).length) { + throw new Error('Unable to capture metadata key/value pairs'); + } + const keysMissingValue = getMissingMetadataKeys(keyValues); + if (keysMissingValue.length > 0) { + throw new Error(`Metadata keys missing: ${keysMissingValue.join(', ')}`); + } +} + +function getMissingMetadataKeys(keyValuePairs: Record): string[] { + return Object.entries(keyValuePairs) + .reduce((acc, [key, value]) => { + if (!value) { + acc.push(key); + } + return acc; + }, new Array()); +} + +/** + * Captures values of properties and getters from the provided instance. + * Necessary because code transformations can make class getters non-enumerable during bundling. + * This ensures that even if getters are non-enumerable, their values are still captured and used. + */ +function capturePropertyValues(instance: unknown): Record { + const obj: Record = {}; + const descriptors = Object.getOwnPropertyDescriptors(instance.constructor.prototype); + + // Capture regular properties from the instance + for (const [key, value] of Object.entries(instance)) { + obj[key] = value; + } + + // Capture getter properties from the instance's prototype + for (const [key, descriptor] of Object.entries(descriptors)) { + if (typeof descriptor.get === 'function') { + obj[key] = descriptor.get.call(instance); + } + } + + return obj; +} diff --git a/src/infrastructure/RuntimeSanity/Common/FactoryValidator.ts b/src/infrastructure/RuntimeSanity/Common/FactoryValidator.ts new file mode 100644 index 00000000..46f0030a --- /dev/null +++ b/src/infrastructure/RuntimeSanity/Common/FactoryValidator.ts @@ -0,0 +1,31 @@ +import { ISanityCheckOptions } from './ISanityCheckOptions'; +import { ISanityValidator } from './ISanityValidator'; + +export type FactoryFunction = () => T; + +export abstract class FactoryValidator implements ISanityValidator { + private readonly factory: FactoryFunction; + + protected constructor(factory: FactoryFunction) { + if (!factory) { + throw new Error('missing factory'); + } + this.factory = factory; + } + + public abstract shouldValidate(options: ISanityCheckOptions): boolean; + + public abstract name: string; + + public* collectErrors(): Iterable { + try { + const value = this.factory(); + if (!value) { + // Do not remove this check, it ensures that the factory call is not optimized away. + yield 'Factory resulted in a falsy value'; + } + } catch (error) { + yield `Error in factory creation: ${error.message}`; + } + } +} diff --git a/src/infrastructure/RuntimeSanity/ISanityCheckOptions.ts b/src/infrastructure/RuntimeSanity/Common/ISanityCheckOptions.ts similarity index 65% rename from src/infrastructure/RuntimeSanity/ISanityCheckOptions.ts rename to src/infrastructure/RuntimeSanity/Common/ISanityCheckOptions.ts index 7dcb25cf..ccbfa7de 100644 --- a/src/infrastructure/RuntimeSanity/ISanityCheckOptions.ts +++ b/src/infrastructure/RuntimeSanity/Common/ISanityCheckOptions.ts @@ -1,3 +1,4 @@ export interface ISanityCheckOptions { readonly validateMetadata: boolean; + readonly validateEnvironment: boolean; } diff --git a/src/infrastructure/RuntimeSanity/ISanityValidator.ts b/src/infrastructure/RuntimeSanity/Common/ISanityValidator.ts similarity index 88% rename from src/infrastructure/RuntimeSanity/ISanityValidator.ts rename to src/infrastructure/RuntimeSanity/Common/ISanityValidator.ts index 3b10d3d3..d5fa7bac 100644 --- a/src/infrastructure/RuntimeSanity/ISanityValidator.ts +++ b/src/infrastructure/RuntimeSanity/Common/ISanityValidator.ts @@ -1,6 +1,7 @@ import { ISanityCheckOptions } from './ISanityCheckOptions'; export interface ISanityValidator { + readonly name: string; shouldValidate(options: ISanityCheckOptions): boolean; collectErrors(): Iterable; } diff --git a/src/infrastructure/RuntimeSanity/SanityChecks.ts b/src/infrastructure/RuntimeSanity/SanityChecks.ts index cf0d208b..38173661 100644 --- a/src/infrastructure/RuntimeSanity/SanityChecks.ts +++ b/src/infrastructure/RuntimeSanity/SanityChecks.ts @@ -1,24 +1,23 @@ -import { ISanityCheckOptions } from './ISanityCheckOptions'; -import { ISanityValidator } from './ISanityValidator'; +import { ISanityCheckOptions } from './Common/ISanityCheckOptions'; +import { ISanityValidator } from './Common/ISanityValidator'; import { MetadataValidator } from './Validators/MetadataValidator'; -const SanityValidators: ISanityValidator[] = [ +const DefaultSanityValidators: ISanityValidator[] = [ new MetadataValidator(), ]; +/* Helps to fail-fast on errors */ export function validateRuntimeSanity( options: ISanityCheckOptions, - validators: readonly ISanityValidator[] = SanityValidators, + validators: readonly ISanityValidator[] = DefaultSanityValidators, ): void { - if (!options) { - throw new Error('missing options'); - } - if (!validators?.length) { - throw new Error('missing validators'); - } + validateContext(options, validators); const errorMessages = validators.reduce((errors, validator) => { if (validator.shouldValidate(options)) { - errors.push(...validator.collectErrors()); + const errorMessage = getErrorMessage(validator); + if (errorMessage) { + errors.push(errorMessage); + } } return errors; }, new Array()); @@ -26,3 +25,26 @@ export function validateRuntimeSanity( throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`); } } + +function validateContext( + options: ISanityCheckOptions, + validators: readonly ISanityValidator[], +) { + if (!options) { + throw new Error('missing options'); + } + if (!validators?.length) { + throw new Error('missing validators'); + } + if (validators.some((validator) => !validator)) { + throw new Error('missing validator in validators'); + } +} + +function getErrorMessage(validator: ISanityValidator): string | undefined { + const errorMessages = [...validator.collectErrors()]; + if (!errorMessages.length) { + return undefined; + } + return `${validator.name}:\n${errorMessages.join('\n')}`; +} diff --git a/src/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.ts b/src/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.ts new file mode 100644 index 00000000..fb383f7c --- /dev/null +++ b/src/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.ts @@ -0,0 +1,16 @@ +import { Environment } from '@/infrastructure/Environment/Environment'; +import { IEnvironment } from '@/infrastructure/Environment/IEnvironment'; +import { ISanityCheckOptions } from '../Common/ISanityCheckOptions'; +import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator'; + +export class EnvironmentValidator extends FactoryValidator { + constructor(factory: FactoryFunction = () => Environment.CurrentEnvironment) { + super(factory); + } + + public override name = 'environment'; + + public override shouldValidate(options: ISanityCheckOptions): boolean { + return options.validateEnvironment; + } +} diff --git a/src/infrastructure/RuntimeSanity/Validators/MetadataValidator.ts b/src/infrastructure/RuntimeSanity/Validators/MetadataValidator.ts index a46b2915..a2c3630d 100644 --- a/src/infrastructure/RuntimeSanity/Validators/MetadataValidator.ts +++ b/src/infrastructure/RuntimeSanity/Validators/MetadataValidator.ts @@ -1,66 +1,16 @@ import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory'; -import { ISanityCheckOptions } from '../ISanityCheckOptions'; -import { ISanityValidator } from '../ISanityValidator'; +import { ISanityCheckOptions } from '../Common/ISanityCheckOptions'; +import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator'; -export class MetadataValidator implements ISanityValidator { - private readonly metadata: IAppMetadata; - - constructor(metadataFactory: () => IAppMetadata = () => AppMetadataFactory.Current) { - this.metadata = metadataFactory(); +export class MetadataValidator extends FactoryValidator { + constructor(factory: FactoryFunction = () => AppMetadataFactory.Current.instance) { + super(factory); } - public shouldValidate(options: ISanityCheckOptions): boolean { + public override name = 'metadata'; + + public override shouldValidate(options: ISanityCheckOptions): boolean { return options.validateMetadata; } - - public* collectErrors(): Iterable { - if (!this.metadata) { - yield 'missing metadata'; - return; - } - const keyValues = capturePropertyValues(this.metadata); - if (!Object.keys(keyValues).length) { - yield 'Unable to capture metadata key/value pairs'; - return; - } - const keysMissingValue = getMissingMetadataKeys(keyValues); - if (keysMissingValue.length > 0) { - yield `Metadata keys missing: ${keysMissingValue.join(', ')}`; - } - } -} - -function getMissingMetadataKeys(keyValuePairs: Record): string[] { - return Object.entries(keyValuePairs) - .reduce((acc, [key, value]) => { - if (!value) { - acc.push(key); - } - return acc; - }, new Array()); -} - -/** - * Captures values of properties and getters from the provided instance. - * Necessary because code transformations can make class getters non-enumerable during bundling. - * This ensures that even if getters are non-enumerable, their values are still captured and used. - */ -function capturePropertyValues(instance: unknown): Record { - const obj: Record = {}; - const descriptors = Object.getOwnPropertyDescriptors(instance.constructor.prototype); - - // Capture regular properties from the instance - for (const [key, value] of Object.entries(instance)) { - obj[key] = value; - } - - // Capture getter properties from the instance's prototype - for (const [key, descriptor] of Object.entries(descriptors)) { - if (typeof descriptor.get === 'function') { - obj[key] = descriptor.get.call(instance); - } - } - - return obj; } diff --git a/src/presentation/bootstrapping/DependencyProvider.ts b/src/presentation/bootstrapping/DependencyProvider.ts index 199de1aa..c5743188 100644 --- a/src/presentation/bootstrapping/DependencyProvider.ts +++ b/src/presentation/bootstrapping/DependencyProvider.ts @@ -5,7 +5,7 @@ import { useCollectionStateKey, useApplicationKey, useEnvironmentKey, } from '@/presentation/injectionSymbols'; import { IApplicationContext } from '@/application/Context/IApplicationContext'; -import { Environment } from '@/application/Environment/Environment'; +import { Environment } from '@/infrastructure/Environment/Environment'; export function provideDependencies(context: IApplicationContext) { registerSingleton(useApplicationKey, useApplication(context.app)); diff --git a/src/presentation/components/App.vue b/src/presentation/components/App.vue index 309396aa..620426e3 100644 --- a/src/presentation/components/App.vue +++ b/src/presentation/components/App.vue @@ -35,6 +35,7 @@ export default defineComponent({ provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts validateRuntimeSanity({ validateMetadata: true, + validateEnvironment: true, }); }, }); diff --git a/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue b/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue index ee632b57..c6551ce0 100644 --- a/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue +++ b/src/presentation/components/Code/CodeButtons/TheCodeButtons.vue @@ -33,7 +33,7 @@ import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injecti import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog'; import { Clipboard } from '@/infrastructure/Clipboard'; import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue'; -import { Environment } from '@/application/Environment/Environment'; +import { Environment } from '@/infrastructure/Environment/Environment'; import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode'; diff --git a/src/presentation/components/Shared/Hooks/UseEnvironment.ts b/src/presentation/components/Shared/Hooks/UseEnvironment.ts index 5ab3a03c..b6c3a6f4 100644 --- a/src/presentation/components/Shared/Hooks/UseEnvironment.ts +++ b/src/presentation/components/Shared/Hooks/UseEnvironment.ts @@ -1,4 +1,4 @@ -import { IEnvironment } from '@/application/Environment/IEnvironment'; +import { IEnvironment } from '@/infrastructure/Environment/IEnvironment'; export function useEnvironment(environment: IEnvironment) { if (!environment) { diff --git a/src/presentation/electron/main/index.ts b/src/presentation/electron/main/index.ts index c3aee27e..87f90d3a 100644 --- a/src/presentation/electron/main/index.ts +++ b/src/presentation/electron/main/index.ts @@ -24,7 +24,12 @@ protocol.registerSchemesAsPrivileged([ setupLogger(); validateRuntimeSanity({ + // Metadata is used by manual updates. validateMetadata: true, + + // Environment is populated by the preload script and is in the renderer's context; + // it's not directly accessible from the main process. + validateEnvironment: false, }); function createWindow() { @@ -34,8 +39,8 @@ function createWindow() { width: size.width, height: size.height, webPreferences: { - nodeIntegration: true, - contextIsolation: false, + nodeIntegration: true, // disabling does not work with electron-vite, https://electron-vite.org/guide/dev.html#nodeintegration + contextIsolation: true, preload: PRELOADER_SCRIPT_PATH, }, icon: APP_ICON_PATH, diff --git a/src/presentation/electron/preload/NodeOsMapper.ts b/src/presentation/electron/preload/NodeOsMapper.ts new file mode 100644 index 00000000..11ac8358 --- /dev/null +++ b/src/presentation/electron/preload/NodeOsMapper.ts @@ -0,0 +1,14 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; + +export function convertPlatformToOs(platform: NodeJS.Platform): OperatingSystem | undefined { + switch (platform) { + case 'darwin': + return OperatingSystem.macOS; + case 'win32': + return OperatingSystem.Windows; + case 'linux': + return OperatingSystem.Linux; + default: + return undefined; + } +} diff --git a/src/presentation/electron/preload/WindowVariablesProvider.ts b/src/presentation/electron/preload/WindowVariablesProvider.ts new file mode 100644 index 00000000..9b022dfd --- /dev/null +++ b/src/presentation/electron/preload/WindowVariablesProvider.ts @@ -0,0 +1,14 @@ +import { createNodeSystemOperations } from '@/infrastructure/Environment/SystemOperations/NodeSystemOperations'; +import { WindowVariables } from '@/infrastructure/Environment/WindowVariables'; +import { convertPlatformToOs } from './NodeOsMapper'; + +export function provideWindowVariables( + createSystem = createNodeSystemOperations, + convertToOs = convertPlatformToOs, +): WindowVariables { + return { + system: createSystem(), + isDesktop: true, + os: convertToOs(process.platform), + }; +} diff --git a/src/presentation/electron/preload/index.ts b/src/presentation/electron/preload/index.ts index e5605790..699c82d4 100644 --- a/src/presentation/electron/preload/index.ts +++ b/src/presentation/electron/preload/index.ts @@ -1,10 +1,22 @@ -// This preload script serves as a placeholder to securely expose Electron APIs to the application. -// As of now, the application does not utilize any specific Electron APIs through this script. +// This file is used to securely expose Electron APIs to the application. + +import { contextBridge } from 'electron'; import log from 'electron-log'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; +import { provideWindowVariables } from './WindowVariablesProvider'; validateRuntimeSanity({ + // Validate metadata as a preventive measure for fail-fast, + // even if it's not currently used in the preload script. validateMetadata: true, + + // The preload script cannot access variables on the window object. + validateEnvironment: false, +}); + +const windowVariables = provideWindowVariables(); +Object.entries(windowVariables).forEach(([key, value]) => { + contextBridge.exposeInMainWorld(key, value); }); // Do not remove [PRELOAD_INIT]; it's a marker used in tests. diff --git a/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts b/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts new file mode 100644 index 00000000..da539e3f --- /dev/null +++ b/tests/integration/infrastructure/RuntimeSanity/SanityChecks.spec.ts @@ -0,0 +1,58 @@ +import { describe } from 'vitest'; +import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; +import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; + +describe('SanityChecks', () => { + describe('validateRuntimeSanity', () => { + describe('does not throw on current environment', () => { + // arrange + const testOptions = generateTestOptions(); + testOptions.forEach((options) => { + it(`options: ${JSON.stringify(options)}`, () => { + // act + const act = () => validateRuntimeSanity(options); + + // assert + expect(act).to.not.throw(); + }); + }); + }); + }); +}); + +function generateTestOptions(): ISanityCheckOptions[] { + const defaultOptions: ISanityCheckOptions = { + validateMetadata: true, + validateEnvironment: true, + }; + return generateBooleanPermutations(defaultOptions); +} + +function generateBooleanPermutations(object: T): T[] { + const keys = Object.keys(object) as (keyof T)[]; + + if (keys.length === 0) { + return [object]; + } + + const currentKey = keys[0]; + const currentValue = object[currentKey]; + + if (typeof currentValue !== 'boolean') { + return generateBooleanPermutations({ + ...object, + [currentKey]: currentValue, + }); + } + + const remainingKeys = Object.fromEntries( + keys.slice(1).map((key) => [key, object[key]]), + ) as unknown as T; + + const subPermutations = generateBooleanPermutations(remainingKeys); + + return [ + ...subPermutations.map((p) => ({ ...p, [currentKey]: true })), + ...subPermutations.map((p) => ({ ...p, [currentKey]: false })), + ]; +} diff --git a/tests/integration/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts b/tests/integration/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts new file mode 100644 index 00000000..dd0a4e2e --- /dev/null +++ b/tests/integration/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts @@ -0,0 +1,15 @@ +import { describe } from 'vitest'; +import { EnvironmentValidator } from '@/infrastructure/RuntimeSanity/Validators/EnvironmentValidator'; +import { FactoryFunction } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator'; +import { EnvironmentStub } from '@tests/unit/shared/Stubs/EnvironmentStub'; +import { IEnvironment } from '@/infrastructure/Environment/IEnvironment'; +import { runFactoryValidatorTests } from './FactoryValidatorConcreteTestRunner'; + +describe('EnvironmentValidator', () => { + runFactoryValidatorTests({ + createValidator: (factory?: FactoryFunction) => new EnvironmentValidator(factory), + enablingOptionProperty: 'validateEnvironment', + factoryFunctionStub: () => new EnvironmentStub(), + expectedValidatorName: 'environment', + }); +}); diff --git a/tests/integration/infrastructure/RuntimeSanity/Validators/FactoryValidatorConcreteTestRunner.ts b/tests/integration/infrastructure/RuntimeSanity/Validators/FactoryValidatorConcreteTestRunner.ts new file mode 100644 index 00000000..580ff9b0 --- /dev/null +++ b/tests/integration/infrastructure/RuntimeSanity/Validators/FactoryValidatorConcreteTestRunner.ts @@ -0,0 +1,60 @@ +import { PropertyKeys } from '@/TypeHelpers'; +import { FactoryFunction, FactoryValidator } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator'; +import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; +import { SanityCheckOptionsStub } from '@tests/unit/shared/Stubs/SanityCheckOptionsStub'; + +interface ITestOptions { + createValidator: (factory?: FactoryFunction) => FactoryValidator; + enablingOptionProperty: PropertyKeys; + factoryFunctionStub: FactoryFunction; + expectedValidatorName: string; +} + +export function runFactoryValidatorTests( + testOptions: ITestOptions, +) { + if (!testOptions) { + throw new Error('missing options'); + } + describe('shouldValidate', () => { + it('returns true when option is true', () => { + // arrange + const expectedValue = true; + const options: ISanityCheckOptions = { + ...new SanityCheckOptionsStub(), + [testOptions.enablingOptionProperty]: true, + }; + const validatorUnderTest = testOptions.createValidator(testOptions.factoryFunctionStub); + // act + const actualValue = validatorUnderTest.shouldValidate(options); + // assert + expect(actualValue).to.equal(expectedValue); + }); + + it('returns false when option is false', () => { + // arrange + const expectedValue = false; + const options: ISanityCheckOptions = { + ...new SanityCheckOptionsStub(), + [testOptions.enablingOptionProperty]: false, + }; + const validatorUnderTest = testOptions.createValidator(testOptions.factoryFunctionStub); + // act + const actualValue = validatorUnderTest.shouldValidate(options); + // assert + expect(actualValue).to.equal(expectedValue); + }); + }); + + describe('name', () => { + it('returns as expected', () => { + // arrange + const expectedName = testOptions.expectedValidatorName; + // act + const validatorUnderTest = testOptions.createValidator(testOptions.factoryFunctionStub); + // assert + const actualName = validatorUnderTest.name; + expect(actualName).to.equal(expectedName); + }); + }); +} diff --git a/tests/integration/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts b/tests/integration/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts new file mode 100644 index 00000000..533bccca --- /dev/null +++ b/tests/integration/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts @@ -0,0 +1,15 @@ +import { describe } from 'vitest'; +import { MetadataValidator } from '@/infrastructure/RuntimeSanity/Validators/MetadataValidator'; +import { FactoryFunction } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator'; +import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub'; +import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; +import { runFactoryValidatorTests } from './FactoryValidatorConcreteTestRunner'; + +describe('MetadataValidator', () => { + runFactoryValidatorTests({ + createValidator: (factory?: FactoryFunction) => new MetadataValidator(factory), + enablingOptionProperty: 'validateMetadata', + factoryFunctionStub: () => new AppMetadataStub(), + expectedValidatorName: 'metadata', + }); +}); diff --git a/tests/unit/application/Common/Enum.spec.ts b/tests/unit/application/Common/Enum.spec.ts index 58d7cb8d..85db029b 100644 --- a/tests/unit/application/Common/Enum.spec.ts +++ b/tests/unit/application/Common/Enum.spec.ts @@ -3,7 +3,7 @@ import { getEnumNames, getEnumValues, createEnumParser, assertInRange, } from '@/application/Common/Enum'; import { scrambledEqual } from '@/application/Common/Array'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { EnumRangeTestRunner } from './EnumRangeTestRunner'; describe('Enum', () => { @@ -37,7 +37,7 @@ describe('Enum', () => { // arrange const enumName = 'ParsableEnum'; const testCases = [ - ...AbsentStringTestCases.map((test) => ({ + ...getAbsentStringTestCases().map((test) => ({ name: test.valueName, value: test.absentValue, expectedError: `missing ${enumName}`, diff --git a/tests/unit/application/Environment/DesktopOsTestCases.ts b/tests/unit/application/Environment/DesktopOsTestCases.ts deleted file mode 100644 index 8abc39e0..00000000 --- a/tests/unit/application/Environment/DesktopOsTestCases.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { OperatingSystem } from '@/domain/OperatingSystem'; - -interface IDesktopTestCase { - processPlatform: string; - expectedOs: OperatingSystem; -} - -// https://nodejs.org/api/process.html#process_process_platform -export const DesktopOsTestCases: ReadonlyArray = [ - { - processPlatform: 'aix', - expectedOs: undefined, - }, - { - processPlatform: 'darwin', - expectedOs: OperatingSystem.macOS, - }, - { - processPlatform: 'freebsd', - expectedOs: undefined, - }, - { - processPlatform: 'linux', - expectedOs: OperatingSystem.Linux, - }, - { - processPlatform: 'openbsd', - expectedOs: undefined, - }, - { - processPlatform: 'sunos', - expectedOs: undefined, - }, - { - processPlatform: 'win32', - expectedOs: OperatingSystem.Windows, - }, -]; diff --git a/tests/unit/application/Environment/Environment.spec.ts b/tests/unit/application/Environment/Environment.spec.ts deleted file mode 100644 index f430eaed..00000000 --- a/tests/unit/application/Environment/Environment.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { IBrowserOsDetector } from '@/application/Environment/BrowserOs/IBrowserOsDetector'; -import { OperatingSystem } from '@/domain/OperatingSystem'; -import { Environment, IEnvironmentVariables } from '@/application/Environment/Environment'; -import { DesktopOsTestCases } from './DesktopOsTestCases'; - -interface EnvironmentVariables { - window?: unknown; - process?: unknown; - navigator?: unknown; -} - -class SystemUnderTest extends Environment { - constructor(variables: EnvironmentVariables, browserOsDetector?: IBrowserOsDetector) { - super(variables as unknown as IEnvironmentVariables, browserOsDetector); - } -} - -describe('Environment', () => { - describe('isDesktop', () => { - it('returns true if process type is renderer', () => { - // arrange - const window = { - process: { - type: 'renderer', - }, - }; - // act - const sut = new SystemUnderTest({ window }); - // assert - expect(sut.isDesktop).to.equal(true); - }); - it('returns true if electron is defined as process version', () => { - // arrange - const process = { - versions: { - electron: true, - }, - }; - // act - const sut = new SystemUnderTest({ process }); - // assert - expect(sut.isDesktop).to.equal(true); - }); - it('returns true if navigator user agent has electron', () => { - // arrange - const navigator = { - userAgent: 'Electron', - }; - // act - const sut = new SystemUnderTest({ navigator }); - // assert - expect(sut.isDesktop).to.equal(true); - }); - it('returns false as default', () => { - const sut = new SystemUnderTest({}); - expect(sut.isDesktop).to.equal(false); - }); - }); - describe('os', () => { - it('returns undefined without user agent', () => { - // arrange - const expected = undefined; - const mock: IBrowserOsDetector = { - detect: () => { - throw new Error('should not reach here'); - }, - }; - const sut = new SystemUnderTest({}, mock); - // act - const actual = sut.os; - // assert - expect(actual).to.equal(expected); - }); - it('browser os from BrowserOsDetector', () => { - // arrange - const givenUserAgent = 'testUserAgent'; - const expected = OperatingSystem.macOS; - const window = { - navigator: { - userAgent: givenUserAgent, - }, - }; - const mock: IBrowserOsDetector = { - detect: (agent) => { - if (agent !== givenUserAgent) { - throw new Error('Unexpected user agent'); - } - return expected; - }, - }; - // act - const sut = new SystemUnderTest({ window }, mock); - const actual = sut.os; - // assert - expect(actual).to.equal(expected); - }); - describe('desktop os', () => { - const navigator = { - userAgent: 'Electron', - }; - for (const testCase of DesktopOsTestCases) { - it(testCase.processPlatform, () => { - // arrange - const process = { - platform: testCase.processPlatform, - }; - // act - const sut = new SystemUnderTest({ navigator, process }); - // assert - expect(sut.os).to.equal(testCase.expectedOs, printMessage()); - function printMessage(): string { - return `Expected: "${OperatingSystem[testCase.expectedOs]}"\n` - + `Actual: "${OperatingSystem[sut.os]}"\n` - + `Platform: "${testCase.processPlatform}"`; - } - }); - } - }); - }); -}); diff --git a/tests/unit/application/Parser/ApplicationParser.spec.ts b/tests/unit/application/Parser/ApplicationParser.spec.ts index 99cec2d2..a7863338 100644 --- a/tests/unit/application/Parser/ApplicationParser.spec.ts +++ b/tests/unit/application/Parser/ApplicationParser.spec.ts @@ -9,7 +9,7 @@ import LinuxData from '@/application/collections/linux.yaml'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { CollectionDataStub } from '@tests/unit/shared/Stubs/CollectionDataStub'; -import { getAbsentCollectionTestCases, AbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentCollectionTestCases, getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub'; import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory'; import { CategoryCollectionParserStub } from '@tests/unit/shared/Stubs/CategoryCollectionParserStub'; @@ -85,7 +85,7 @@ describe('ApplicationParser', () => { }); it('defaults to metadata from factory', () => { // arrange - const expectedMetadata = AppMetadataFactory.Current; + const expectedMetadata: IAppMetadata = AppMetadataFactory.Current.instance; const infoParserStub = new ProjectInformationParserStub(); // act new ApplicationParserBuilder() @@ -157,7 +157,7 @@ describe('ApplicationParser', () => { value: testCase.absentValue, expectedError: 'missing collections', })).filter((test) => test.value !== undefined /* the default value is set */), - ...AbsentObjectTestCases.map((testCase) => ({ + ...getAbsentObjectTestCases().map((testCase) => ({ name: `given absent item "${testCase.valueName}"`, value: [testCase.absentValue], expectedError: 'missing collection provided', diff --git a/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts b/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts index 647ded61..383eb94c 100644 --- a/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts +++ b/tests/unit/application/Parser/NodeValidation/NodeValidatorTestRunner.ts @@ -1,7 +1,7 @@ import { describe, it } from 'vitest'; import { NodeDataError, INodeDataErrorContext } from '@/application/Parser/NodeValidation/NodeDataError'; import { NodeData } from '@/application/Parser/NodeValidation/NodeData'; -import { AbsentObjectTestCases, AbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases, getAbsentStringTestCases, itEachAbsentTestCase } from '@tests/unit/shared/TestCases/AbsentTests'; import { expectThrowsError } from '@tests/unit/shared/Assertions/ExpectThrowsError'; export interface ITestScenario { @@ -16,7 +16,7 @@ export class NodeValidationTestRunner { describe('throws given invalid names', () => { // arrange const testCases = [ - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ testName: `missing name (${testCase.valueName})`, nameValue: testCase.absentValue, expectedMessage: 'missing name', @@ -42,7 +42,7 @@ export class NodeValidationTestRunner { ) { describe('throws given missing node data', () => { itEachAbsentTestCase([ - ...AbsentObjectTestCases, + ...getAbsentObjectTestCases(), { valueName: 'empty object', absentValue: {}, diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts index 1d8b8667..883775a5 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Expression/Expression.spec.ts @@ -9,7 +9,7 @@ import { ExpressionEvaluationContextStub } from '@tests/unit/shared/Stubs/Expres import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressions/Pipes/IPipelineCompiler'; import { PipelineCompilerStub } from '@tests/unit/shared/Stubs/PipelineCompilerStub'; import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/IFunctionParameterCollection'; -import { AbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext'; describe('Expression', () => { @@ -91,7 +91,7 @@ describe('Expression', () => { expectedError: string, sutBuilder?: (builder: ExpressionBuilder) => ExpressionBuilder, }[] = [ - ...AbsentObjectTestCases.map((testCase) => ({ + ...getAbsentObjectTestCases().map((testCase) => ({ name: `throws if arguments is ${testCase.valueName}`, context: testCase.absentValue, expectedError: 'missing context', diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts index cebbb80a..bce9d031 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes.spec.ts @@ -1,6 +1,6 @@ import { describe } from 'vitest'; import { EscapeDoubleQuotes } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeDefinitions/EscapeDoubleQuotes'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { runPipeTests } from './PipeTestRunner'; describe('EscapeDoubleQuotes', () => { @@ -23,7 +23,7 @@ describe('EscapeDoubleQuotes', () => { input: '""hello world""', expectedOutput: '"^"""^""hello world"^"""^""', }, - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: 'returns as it is when if input is missing', input: testCase.absentValue, expectedOutput: testCase.absentValue, diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts index e27d1292..9315444f 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { PipeFactory } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory'; import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub'; -import { AbsentStringTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; describe('PipeFactory', () => { describe('ctor', () => { @@ -82,7 +82,7 @@ describe('PipeFactory', () => { function testPipeNameValidation(testRunner: (invalidName: string) => void) { const testCases = [ // Validate missing value - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `empty pipe name (${testCase.valueName})`, value: testCase.absentValue, expectedError: 'empty pipe name', diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts index efde0382..74e41493 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/Pipes/PipelineCompiler.spec.ts @@ -4,7 +4,7 @@ import { IPipelineCompiler } from '@/application/Parser/Script/Compiler/Expressi import { IPipeFactory } from '@/application/Parser/Script/Compiler/Expressions/Pipes/PipeFactory'; import { PipeStub } from '@tests/unit/shared/Stubs/PipeStub'; import { PipeFactoryStub } from '@tests/unit/shared/Stubs/PipeFactoryStub'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; describe('PipelineCompiler', () => { describe('compile', () => { @@ -15,12 +15,12 @@ describe('PipelineCompiler', () => { expectedError: string; } const testCases: ITestCase[] = [ - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `"value" is ${testCase.valueName}`, act: (test) => test.withValue(testCase.absentValue), expectedError: 'missing value', })), - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `"pipeline" is ${testCase.valueName}`, act: (test) => test.withPipeline(testCase.absentValue), expectedError: 'missing pipeline', diff --git a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts index 86d29446..20657592 100644 --- a/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser.spec.ts @@ -1,7 +1,7 @@ import { describe } from 'vitest'; import { ExpressionPosition } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionPosition'; import { WithParser } from '@/application/Parser/Script/Compiler/Expressions/SyntaxParsers/WithParser'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; import { SyntaxParserTestsRunner } from './SyntaxParserTestsRunner'; describe('WithParser', () => { @@ -86,7 +86,7 @@ describe('WithParser', () => { describe('renders scope conditionally', () => { describe('does not render scope if argument is undefined', () => { runner.expectResults( - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `does not render when value is "${testCase.valueName}"`, code: '{{ with $parameter }}dark{{ end }} ', args: (args) => args diff --git a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts index b7460648..0d1d746b 100644 --- a/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts +++ b/tests/unit/application/Parser/Script/Compiler/Function/SharedFunction.spec.ts @@ -6,7 +6,7 @@ import { IFunctionCall } from '@/application/Parser/Script/Compiler/Function/Cal import { FunctionCallStub } from '@tests/unit/shared/Stubs/FunctionCallStub'; import { FunctionBodyType, ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; import { - AbsentStringTestCases, itEachAbsentCollectionValue, itEachAbsentObjectValue, + getAbsentStringTestCases, itEachAbsentCollectionValue, itEachAbsentObjectValue, itEachAbsentStringValue, } from '@tests/unit/shared/TestCases/AbsentTests'; @@ -100,7 +100,7 @@ describe('SharedFunction', () => { // arrange const testData = [ 'expected-revert-code', - ...AbsentStringTestCases.map((testCase) => testCase.absentValue), + ...getAbsentStringTestCases().map((testCase) => testCase.absentValue), ]; for (const data of testData) { // act diff --git a/tests/unit/application/Parser/Script/Compiler/ParameterNameTestRunner.ts b/tests/unit/application/Parser/Script/Compiler/ParameterNameTestRunner.ts index 444cd6e9..28be8a81 100644 --- a/tests/unit/application/Parser/Script/Compiler/ParameterNameTestRunner.ts +++ b/tests/unit/application/Parser/Script/Compiler/ParameterNameTestRunner.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; export function testParameterName(action: (parameterName: string) => string) { describe('name', () => { @@ -22,7 +22,7 @@ export function testParameterName(action: (parameterName: string) => string) { describe('throws if invalid', () => { // arrange const testCases = [ - ...AbsentStringTestCases.map((test) => ({ + ...getAbsentStringTestCases().map((test) => ({ name: test.valueName, value: test.absentValue, expectedError: 'missing parameter name', diff --git a/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts b/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts index 8dbbea02..861f680b 100644 --- a/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts +++ b/tests/unit/application/Parser/ScriptingDefinition/CodeSubstituter.spec.ts @@ -3,13 +3,13 @@ import { CodeSubstituter } from '@/application/Parser/ScriptingDefinition/CodeSu import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; import { ProjectInformationStub } from '@tests/unit/shared/Stubs/ProjectInformationStub'; import { ExpressionsCompilerStub } from '@tests/unit/shared/Stubs/ExpressionsCompilerStub'; -import { AbsentObjectTestCases, AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases, getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; describe('CodeSubstituter', () => { describe('throws with invalid parameters', () => { // arrange const testCases = [ - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `given code: ${testCase.valueName}`, expectedError: 'missing code', parameters: { @@ -17,7 +17,7 @@ describe('CodeSubstituter', () => { info: new ProjectInformationStub(), }, })), - ...AbsentObjectTestCases.map((testCase) => ({ + ...getAbsentObjectTestCases().map((testCase) => ({ name: `given info: ${testCase.valueName}`, expectedError: 'missing info', parameters: { diff --git a/tests/unit/domain/Application.spec.ts b/tests/unit/domain/Application.spec.ts index a99ce53d..7d4442d1 100644 --- a/tests/unit/domain/Application.spec.ts +++ b/tests/unit/domain/Application.spec.ts @@ -3,7 +3,7 @@ import { Application } from '@/domain/Application'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { CategoryCollectionStub } from '@tests/unit/shared/Stubs/CategoryCollectionStub'; import { ProjectInformationStub } from '@tests/unit/shared/Stubs/ProjectInformationStub'; -import { AbsentObjectTestCases, getAbsentCollectionTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases, getAbsentCollectionTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { ICategoryCollection } from '@/domain/ICategoryCollection'; describe('Application', () => { @@ -69,7 +69,7 @@ describe('Application', () => { expectedError: 'missing collections', value: testCase.absentValue, })), - ...AbsentObjectTestCases.map((testCase) => ({ + ...getAbsentObjectTestCases().map((testCase) => ({ name: `${testCase.valueName} value in list`, expectedError: 'missing collection in the list', value: [new CategoryCollectionStub(), testCase.absentValue], diff --git a/tests/unit/domain/ScriptCode.spec.ts b/tests/unit/domain/ScriptCode.spec.ts index 6b619792..3143d7ce 100644 --- a/tests/unit/domain/ScriptCode.spec.ts +++ b/tests/unit/domain/ScriptCode.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ScriptCode } from '@/domain/ScriptCode'; -import { AbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentStringTestCases } from '@tests/unit/shared/TestCases/AbsentTests'; describe('ScriptCode', () => { describe('code', () => { @@ -15,7 +15,7 @@ describe('ScriptCode', () => { }, expectedError: '(revert): Code itself and its reverting code cannot be the same', }, - ...AbsentStringTestCases.map((testCase) => ({ + ...getAbsentStringTestCases().map((testCase) => ({ name: `cannot construct with ${testCase.valueName} "execute"`, code: { execute: testCase.absentValue, diff --git a/tests/unit/infrastructure/CodeRunner.spec.ts b/tests/unit/infrastructure/CodeRunner.spec.ts index 58fc3b61..6ff6a0d7 100644 --- a/tests/unit/infrastructure/CodeRunner.spec.ts +++ b/tests/unit/infrastructure/CodeRunner.spec.ts @@ -3,16 +3,48 @@ import { EnvironmentStub } from '@tests/unit/shared/Stubs/EnvironmentStub'; import { OperatingSystem } from '@/domain/OperatingSystem'; import { CodeRunner } from '@/infrastructure/CodeRunner'; import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync'; +import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; +import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystemOpsStub'; +import { LocationOpsStub } from '@tests/unit/shared/Stubs/LocationOpsStub'; +import { FileSystemOpsStub } from '@tests/unit/shared/Stubs/FileSystemOpsStub'; +import { CommandOpsStub } from '@tests/unit/shared/Stubs/CommandOpsStub'; +import { IFileSystemOps, ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { FunctionKeys } from '@/TypeHelpers'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; describe('CodeRunner', () => { + describe('ctor throws if system is missing', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing system operations'; + const environment = new EnvironmentStub() + .withSystemOperations(absentValue); + // act + const act = () => new CodeRunner(environment); + // assert + expect(act).to.throw(expectedError); + }); + }); describe('runCode', () => { it('creates temporary directory recursively', async () => { // arrange const expectedDir = 'expected-dir'; + const expectedIsRecursive = true; + const folderName = 'privacy.sexy'; - const context = new TestContext(); - context.mocks.os.setupTmpdir('tmp'); - context.mocks.path.setupJoin(expectedDir, 'tmp', folderName); + 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)); // act await context @@ -20,22 +52,34 @@ describe('CodeRunner', () => { .runCode(); // assert - expect(context.mocks.fs.mkdirHistory.length).to.equal(1); - expect(context.mocks.fs.mkdirHistory[0].isRecursive).to.equal(true); - expect(context.mocks.fs.mkdirHistory[0].path).to.equal(expectedDir); + 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'; + const filesystem = new FileSystemOpsStub(); const extension = '.sh'; const expectedName = `run.${extension}`; const folderName = 'privacy.sexy'; - const context = new TestContext(); - context.mocks.os.setupTmpdir('tmp'); - context.mocks.path.setupJoin('folder', 'tmp', folderName); - context.mocks.path.setupJoin(expectedFilePath, 'folder', expectedName); + 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 @@ -45,22 +89,34 @@ describe('CodeRunner', () => { .runCode(); // assert - expect(context.mocks.fs.writeFileHistory.length).to.equal(1); - expect(context.mocks.fs.writeFileHistory[0].data).to.equal(expectedCode); - expect(context.mocks.fs.writeFileHistory[0].path).to.equal(expectedFilePath); + 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); }); it('set file permissions as expected', async () => { // arrange const expectedMode = '755'; const expectedFilePath = 'expected-file-path'; + const filesystem = new FileSystemOpsStub(); const extension = '.sh'; const expectedName = `run.${extension}`; const folderName = 'privacy.sexy'; - const context = new TestContext(); - context.mocks.os.setupTmpdir('tmp'); - context.mocks.path.setupJoin('folder', 'tmp', folderName); - context.mocks.path.setupJoin(expectedFilePath, 'folder', expectedName); + 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 @@ -69,57 +125,74 @@ describe('CodeRunner', () => { .runCode(); // assert - expect(context.mocks.fs.chmodCallHistory.length).to.equal(1); - expect(context.mocks.fs.chmodCallHistory[0].mode).to.equal(expectedMode); - expect(context.mocks.fs.chmodCallHistory[0].path).to.equal(expectedFilePath); + 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); }); describe('executes as expected', () => { // arrange const filePath = 'expected-file-path'; - const testData = [ + interface IExecutionTestCase { + readonly givenOs: OperatingSystem; + readonly expectedCommand: string; + } + const testData: readonly IExecutionTestCase[] = [ { - os: OperatingSystem.Windows, - expected: filePath, + givenOs: OperatingSystem.Windows, + expectedCommand: filePath, }, { - os: OperatingSystem.macOS, - expected: `open -a Terminal.app ${filePath}`, + givenOs: OperatingSystem.macOS, + expectedCommand: `open -a Terminal.app ${filePath}`, }, { - os: OperatingSystem.Linux, - expected: `x-terminal-emulator -e '${filePath}'`, + givenOs: OperatingSystem.Linux, + expectedCommand: `x-terminal-emulator -e '${filePath}'`, }, ]; - for (const data of testData) { - it(`returns ${data.expected} on ${OperatingSystem[data.os]}`, async () => { - const context = new TestContext(); - context.mocks.os.setupTmpdir('non-important-temp-dir-name'); - context.mocks.path.setupJoinSequence('non-important-folder-name', filePath); - context.withOs(data.os); + 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)); // act await context - .withOs(data.os) + .withOs(givenOs) .runCode(); // assert - expect(context.mocks.child_process.executionHistory.length).to.equal(1); - expect(context.mocks.child_process.executionHistory[0]).to.equal(data.expected); + 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 () => { - // arrange - const expectedOrder = [NodeJsCommand.mkdir, NodeJsCommand.writeFile, NodeJsCommand.chmod]; - const context = new TestContext(); - context.mocks.os.setupTmpdir('non-important-temp-dir-name'); - context.mocks.path.setupJoinSequence('non-important-folder-name1', 'non-important-folder-name2'); + 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)); // act await context.runCode(); // assert - const actualOrder = context.mocks.commandHistory + const actualOrder = fileSystem.callHistory + .map((c) => c.methodName) .filter((command) => expectedOrder.includes(command)); expect(expectedOrder).to.deep.equal(actualOrder); }); @@ -138,23 +211,40 @@ describe('CodeRunner', () => { }); class TestContext { - public mocks = getNodeJsMocks(); - private code = 'code'; private folderName = 'folderName'; private fileExtension = 'fileExtension'; - private env = mockEnvironment(OperatingSystem.Windows); + private os = OperatingSystem.Windows; + + private systemOperations: ISystemOperations = new SystemOperationsStub(); public async runCode(): Promise { - const runner = new CodeRunner(this.mocks, this.env); + const environment = new EnvironmentStub() + .withOs(this.os) + .withSystemOperations(this.systemOperations); + const runner = new CodeRunner(environment); await runner.runCode(this.code, this.folderName, this.fileExtension); } + public withSystemOperations( + systemOperations: ISystemOperations, + ): this { + this.systemOperations = systemOperations; + return this; + } + + public withSystemOperationsStub( + setup: (stub: SystemOperationsStub) => SystemOperationsStub, + ): this { + const stub = setup(new SystemOperationsStub()); + return this.withSystemOperations(stub); + } + public withOs(os: OperatingSystem) { - this.env = mockEnvironment(os); + this.os = os; return this; } @@ -173,104 +263,3 @@ class TestContext { return this; } } - -function mockEnvironment(os: OperatingSystem) { - return new EnvironmentStub().withOs(os); -} - -const enum NodeJsCommand { tmpdir, join, exec, mkdir, writeFile, chmod } - -function getNodeJsMocks() { - const commandHistory = new Array(); - return { - os: mockOs(commandHistory), - path: mockPath(commandHistory), - fs: mockNodeFs(commandHistory), - child_process: mockChildProcess(commandHistory), - commandHistory, - }; -} - -function mockOs(commandHistory: NodeJsCommand[]) { - let tmpDir = '/stub-temp-dir/'; - return { - setupTmpdir: (value: string): void => { - tmpDir = value; - }, - tmpdir: (): string => { - if (!tmpDir) { - throw new Error('tmpdir not set up'); - } - commandHistory.push(NodeJsCommand.tmpdir); - return tmpDir; - }, - }; -} - -function mockPath(commandHistory: NodeJsCommand[]) { - const sequence = new Array(); - const scenarios = new Map(); - const getScenarioKey = (paths: string[]) => paths.join('|'); - return { - setupJoin: (returnValue: string, ...paths: string[]): void => { - scenarios.set(getScenarioKey(paths), returnValue); - }, - setupJoinSequence: (...valuesToReturn: string[]): void => { - sequence.push(...valuesToReturn); - sequence.reverse(); - }, - join: (...paths: string[]): string => { - commandHistory.push(NodeJsCommand.join); - if (sequence.length > 0) { - return sequence.pop(); - } - const key = getScenarioKey(paths); - if (!scenarios.has(key)) { - return paths.join('/'); - } - return scenarios.get(key); - }, - }; -} - -function mockChildProcess(commandHistory: NodeJsCommand[]) { - const executionHistory = new Array(); - return { - exec: (command: string): void => { - commandHistory.push(NodeJsCommand.exec); - executionHistory.push(command); - }, - executionHistory, - }; -} - -function mockNodeFs(commandHistory: NodeJsCommand[]) { - interface IMkdirCall { path: string; isRecursive: boolean; } - interface IWriteFileCall { path: string; data: string; } - interface IChmodCall { path: string; mode: string | number; } - const mkdirHistory = new Array(); - const writeFileHistory = new Array(); - const chmodCallHistory = new Array(); - return { - promises: { - mkdir: (path, options) => { - commandHistory.push(NodeJsCommand.mkdir); - mkdirHistory.push({ path, isRecursive: options && options.recursive }); - return Promise.resolve(path); - }, - writeFile: (path, data) => { - commandHistory.push(NodeJsCommand.writeFile); - writeFileHistory.push({ path, data }); - return Promise.resolve(); - }, - chmod: (path, mode) => { - commandHistory.push(NodeJsCommand.chmod); - chmodCallHistory.push({ path, mode }); - return Promise.resolve(); - }, - }, - mkdirHistory, - writeFileHistory, - chmodCallHistory, - }; -} diff --git a/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts b/tests/unit/infrastructure/Environment/BrowserOs/BrowserOsDetector.spec.ts similarity index 92% rename from tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts rename to tests/unit/infrastructure/Environment/BrowserOs/BrowserOsDetector.spec.ts index 7f81957d..5368be65 100644 --- a/tests/unit/application/Environment/BrowserOs/BrowserOsDetector.spec.ts +++ b/tests/unit/infrastructure/Environment/BrowserOs/BrowserOsDetector.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { OperatingSystem } from '@/domain/OperatingSystem'; -import { BrowserOsDetector } from '@/application/Environment/BrowserOs/BrowserOsDetector'; +import { BrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/BrowserOsDetector'; import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests'; import { BrowserOsTestCases } from './BrowserOsTestCases'; diff --git a/tests/unit/application/Environment/BrowserOs/BrowserOsTestCases.ts b/tests/unit/infrastructure/Environment/BrowserOs/BrowserOsTestCases.ts similarity index 100% rename from tests/unit/application/Environment/BrowserOs/BrowserOsTestCases.ts rename to tests/unit/infrastructure/Environment/BrowserOs/BrowserOsTestCases.ts diff --git a/tests/unit/infrastructure/Environment/Environment.spec.ts b/tests/unit/infrastructure/Environment/Environment.spec.ts new file mode 100644 index 00000000..f2e5d120 --- /dev/null +++ b/tests/unit/infrastructure/Environment/Environment.spec.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { IBrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/IBrowserOsDetector'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { Environment, WindowValidator } from '@/infrastructure/Environment/Environment'; +import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { WindowVariables } from '@/infrastructure/Environment/WindowVariables'; +import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub'; + +describe('Environment', () => { + describe('ctor', () => { + describe('throws if window is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing window'; + const absentWindow = absentValue; + // act + const act = () => createEnvironment({ + window: absentWindow, + }); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('isDesktop', () => { + it('returns true when window property isDesktop is true', () => { + // arrange + const desktopWindow = { + isDesktop: true, + }; + // act + const sut = createEnvironment({ + window: desktopWindow, + }); + // assert + expect(sut.isDesktop).to.equal(true); + }); + it('returns false when window property isDesktop is false', () => { + // arrange + const expectedValue = false; + const browserWindow = { + isDesktop: false, + }; + // act + const sut = createEnvironment({ + window: browserWindow, + }); + // assert + expect(sut.isDesktop).to.equal(expectedValue); + }); + }); + describe('os', () => { + it('returns undefined if user agent is missing', () => { + // arrange + const expected = undefined; + const browserDetectorMock: IBrowserOsDetector = { + detect: () => { + throw new Error('should not reach here'); + }, + }; + const sut = createEnvironment({ + browserOsDetector: browserDetectorMock, + }); + // act + const actual = sut.os; + // assert + expect(actual).to.equal(expected); + }); + it('gets browser os from BrowserOsDetector', () => { + // arrange + const givenUserAgent = 'testUserAgent'; + const expected = OperatingSystem.macOS; + const windowWithUserAgent = { + navigator: { + userAgent: givenUserAgent, + }, + }; + const browserDetectorMock: IBrowserOsDetector = { + detect: (agent) => { + if (agent !== givenUserAgent) { + throw new Error('Unexpected user agent'); + } + return expected; + }, + }; + // act + const sut = createEnvironment({ + window: windowWithUserAgent as Partial, + browserOsDetector: browserDetectorMock, + }); + const actual = sut.os; + // assert + expect(actual).to.equal(expected); + }); + describe('desktop os', () => { + describe('returns from window property `os`', () => { + const testValues = [ + OperatingSystem.macOS, + OperatingSystem.Windows, + OperatingSystem.Linux, + ]; + testValues.forEach((testValue) => { + it(`given ${OperatingSystem[testValue]}`, () => { + // arrange + const expectedOs = testValue; + const desktopWindowWithOs = { + isDesktop: true, + os: expectedOs, + }; + // act + const sut = createEnvironment({ + window: desktopWindowWithOs, + }); + // assert + const actualOs = sut.os; + expect(actualOs).to.equal(expectedOs); + }); + }); + }); + describe('returns undefined when window property `os` is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedValue = undefined; + const windowWithAbsentOs = { + os: absentValue, + }; + // act + const sut = createEnvironment({ + window: windowWithAbsentOs, + }); + // assert + expect(sut.os).to.equal(expectedValue); + }); + }); + }); + }); + describe('system', () => { + it('fetches system operations from window', () => { + // arrange + const expectedSystem = new SystemOperationsStub(); + const windowWithSystem = { + system: expectedSystem, + }; + // act + const sut = createEnvironment({ + window: windowWithSystem, + }); + // assert + const actualSystem = sut.system; + expect(actualSystem).to.equal(expectedSystem); + }); + }); + describe('validateWindow', () => { + it('throws when validator throws', () => { + // arrange + const expectedErrorMessage = 'expected error thrown from window validator'; + const mockValidator: WindowValidator = () => { + throw new Error(expectedErrorMessage); + }; + // act + const act = () => createEnvironment({ + windowValidator: mockValidator, + }); + // assert + expect(act).to.throw(expectedErrorMessage); + }); + it('does not throw when validator does not throw', () => { + // arrange + const expectedErrorMessage = 'expected error thrown from window validator'; + const mockValidator: WindowValidator = () => { + // do not throw + }; + // act + const act = () => createEnvironment({ + windowValidator: mockValidator, + }); + // assert + expect(act).to.not.throw(expectedErrorMessage); + }); + it('sends expected window to validator', () => { + // arrange + const expectedVariables: Partial = {}; + let actualVariables: Partial; + const mockValidator: WindowValidator = (variables) => { + actualVariables = variables; + }; + // act + createEnvironment({ + window: expectedVariables, + windowValidator: mockValidator, + }); + // assert + expect(actualVariables).to.equal(expectedVariables); + }); + }); +}); + +interface EnvironmentOptions { + window: Partial; + browserOsDetector?: IBrowserOsDetector; + windowValidator?: WindowValidator; +} + +function createEnvironment(options: Partial = {}): TestableEnvironment { + const defaultOptions: EnvironmentOptions = { + window: {}, + browserOsDetector: new BrowserOsDetectorStub(), + windowValidator: () => { /* NO OP */ }, + }; + + return new TestableEnvironment({ ...defaultOptions, ...options }); +} + +class TestableEnvironment extends Environment { + public constructor(options: EnvironmentOptions) { + super(options.window, options.browserOsDetector, options.windowValidator); + } +} diff --git a/tests/unit/infrastructure/Environment/WindowVariablesValidator.spec.ts b/tests/unit/infrastructure/Environment/WindowVariablesValidator.spec.ts new file mode 100644 index 00000000..cf2311b5 --- /dev/null +++ b/tests/unit/infrastructure/Environment/WindowVariablesValidator.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect } from 'vitest'; +import { validateWindowVariables } from '@/infrastructure/Environment/WindowVariablesValidator'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; +import { WindowVariables } from '@/infrastructure/Environment/WindowVariables'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; + +describe('WindowVariablesValidator', () => { + describe('validateWindowVariables', () => { + describe('invalid types', () => { + it('throws an error if variables is not an object', () => { + // arrange + const expectedError = 'window is not an object but string'; + const variablesAsString = 'not an object'; + // act + const act = () => validateWindowVariables(variablesAsString as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + it('throws an error if variables is an array', () => { + // arrange + const expectedError = 'window is not an object but object'; + const arrayVariables: unknown = []; + // act + const act = () => validateWindowVariables(arrayVariables as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + describe('throws an error if variables is null', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing variables'; + const variables = absentValue; + // act + const act = () => validateWindowVariables(variables as unknown); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + + describe('property validations', () => { + it('throws an error with a description of all invalid properties', () => { + // arrange + const expectedError = 'Unexpected os (string)\nUnexpected isDesktop (string)'; + const input = { + os: 'invalid', + isDesktop: 'not a boolean', + }; + // act + const act = () => validateWindowVariables(input as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + describe('`os` property', () => { + it('throws an error when os is not a number', () => { + // arrange + const expectedError = 'Unexpected os (string)'; + const input = { + os: 'Linux', + }; + // act + const act = () => validateWindowVariables(input as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + it('throws an error for an invalid numeric os value', () => { + // arrange + const expectedError = 'Unexpected os (number)'; + const input = { + os: Number.MAX_SAFE_INTEGER, + }; + // act + const act = () => validateWindowVariables(input as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + it('does not throw for a missing os value', () => { + const input = { + isDesktop: true, + system: new SystemOperationsStub(), + }; + // act + const act = () => validateWindowVariables(input); + // assert + expect(act).to.not.throw(); + }); + }); + + describe('`isDesktop` property', () => { + it('throws an error when only isDesktop is provided and it is true without a system object', () => { + // arrange + const expectedError = 'Unexpected system (undefined)'; + const input = { + isDesktop: true, + }; + // act + const act = () => validateWindowVariables(input as unknown); + // assert + expect(act).to.throw(expectedError); + }); + + it('does not throw when isDesktop is true with a valid system object', () => { + // arrange + const input = { + isDesktop: true, + system: new SystemOperationsStub(), + }; + // act + const act = () => validateWindowVariables(input); + // assert + expect(act).to.not.throw(); + }); + + it('does not throw when isDesktop is false without a system object', () => { + // arrange + const input = { + isDesktop: false, + }; + // act + const act = () => validateWindowVariables(input); + // assert + expect(act).to.not.throw(); + }); + }); + + describe('`system` property', () => { + it('throws an error if system is not an object', () => { + // arrange + const expectedError = 'Unexpected system (string)'; + const input = { + isDesktop: true, + system: 'invalid system', + }; + // act + const act = () => validateWindowVariables(input as unknown); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + + it('does not throw for a valid object', () => { + const input: WindowVariables = { + os: OperatingSystem.Windows, + isDesktop: true, + system: new SystemOperationsStub(), + }; + // act + const act = () => validateWindowVariables(input); + // assert + expect(act).to.not.throw(); + }); + }); +}); diff --git a/tests/unit/infrastructure/Metadata/AppMetadataFactory.spec.ts b/tests/unit/infrastructure/Metadata/AppMetadataFactory.spec.ts index 2471afee..94d7e2c3 100644 --- a/tests/unit/infrastructure/Metadata/AppMetadataFactory.spec.ts +++ b/tests/unit/infrastructure/Metadata/AppMetadataFactory.spec.ts @@ -2,14 +2,52 @@ import { describe, } from 'vitest'; import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests'; -import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory'; +import { AppMetadataFactory, MetadataValidator } from '@/infrastructure/Metadata/AppMetadataFactory'; import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata'; +import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; + +class TestableAppMetadataFactory extends AppMetadataFactory { + public constructor(validator: MetadataValidator = () => { /* NO OP */ }) { + super(validator); + } +} describe('AppMetadataFactory', () => { describe('instance', () => { itIsSingleton({ - getter: () => AppMetadataFactory.Current, + getter: () => AppMetadataFactory.Current.instance, expectedType: ViteAppMetadata, }); }); + it('creates the correct type of metadata', () => { + // arrange + const sut = new TestableAppMetadataFactory(); + // act + const metadata = sut.instance; + // assert + expect(metadata).to.be.instanceOf(ViteAppMetadata); + }); + it('validates its instance', () => { + // arrange + let validatedMetadata: IAppMetadata; + const validatorMock = (metadata: IAppMetadata) => { + validatedMetadata = metadata; + }; + // act + const sut = new TestableAppMetadataFactory(validatorMock); + const actualInstance = sut.instance; + // assert + expect(actualInstance).to.equal(validatedMetadata); + }); + it('throws error if validator fails', () => { + // arrange + const expectedError = 'validator failed'; + const failingValidator = () => { + throw new Error(expectedError); + }; + // act + const act = () => new TestableAppMetadataFactory(failingValidator); + // assert + expect(act).to.throw(expectedError); + }); }); diff --git a/tests/unit/infrastructure/Metadata/MetadataValidator.spec.ts b/tests/unit/infrastructure/Metadata/MetadataValidator.spec.ts new file mode 100644 index 00000000..5bf31738 --- /dev/null +++ b/tests/unit/infrastructure/Metadata/MetadataValidator.spec.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub'; +import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; +import { validateMetadata } from '@/infrastructure/Metadata/MetadataValidator'; + +describe('MetadataValidator', () => { + it('does not throw if all metadata keys have values', () => { + // arrange + const metadata = new AppMetadataStub(); + // act + const act = () => validateMetadata(metadata); + // assert + expect(act).to.not.throw(); + }); + describe('throws as expected', () => { + describe('"missing metadata" if metadata is not provided', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing metadata'; + const metadata = absentValue; + // act + const act = () => validateMetadata(metadata); + // assert + expect(act).to.throw(expectedError); + }); + }); + it('"missing keys" if metadata has properties with missing values', () => { + // arrange + const expectedError = 'Metadata keys missing: name, homepageUrl'; + const missingData: Partial = { + name: undefined, + homepageUrl: undefined, + }; + const metadata: IAppMetadata = { + ...new AppMetadataStub(), + ...missingData, + }; + // act + const act = () => validateMetadata(metadata); + // assert + expect(act).to.throw(expectedError); + }); + it('"missing keys" if metadata has getters with missing values', () => { + // arrange + const expectedError = 'Metadata keys missing: name, homepageUrl'; + const stubWithGetters: Partial = { + get name() { + return undefined; + }, + get homepageUrl() { + return undefined; + }, + }; + const metadata: IAppMetadata = { + ...new AppMetadataStub(), + ...stubWithGetters, + }; + // act + const act = () => validateMetadata(metadata); + // assert + expect(act).to.throw(expectedError); + }); + it('"unable to capture metadata" if metadata has no getters or properties', () => { + // arrange + const expectedError = 'Unable to capture metadata key/value pairs'; + const metadata = {} as IAppMetadata; + // act + const act = () => validateMetadata(metadata); + // assert + expect(act).to.throw(expectedError); + }); + }); +}); diff --git a/tests/unit/infrastructure/Metadata/ViteAppMetadata.spec.ts b/tests/unit/infrastructure/Metadata/ViteAppMetadata.spec.ts index 2e3fa19c..b3004d7b 100644 --- a/tests/unit/infrastructure/Metadata/ViteAppMetadata.spec.ts +++ b/tests/unit/infrastructure/Metadata/ViteAppMetadata.spec.ts @@ -21,7 +21,7 @@ describe('ViteAppMetadata', () => { keyof typeof VITE_ENVIRONMENT_KEYS]; readonly expected: string; } - const testCases: { [K in PropertyKeys]: ITestCase } = { + const testCases: { readonly [K in PropertyKeys]: ITestCase } = { name: { environmentVariable: VITE_ENVIRONMENT_KEYS.NAME, expected: 'expected-name', diff --git a/tests/unit/infrastructure/RuntimeSanity/Common/FactoryValidator.spec.ts b/tests/unit/infrastructure/RuntimeSanity/Common/FactoryValidator.spec.ts new file mode 100644 index 00000000..9ef2878f --- /dev/null +++ b/tests/unit/infrastructure/RuntimeSanity/Common/FactoryValidator.spec.ts @@ -0,0 +1,116 @@ +import { describe } from 'vitest'; +import { FactoryValidator, FactoryFunction } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator'; +import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; + +describe('FactoryValidator', () => { + describe('ctor', () => { + describe('throws when factory is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing factory'; + const factory = absentValue; + // act + const act = () => new TestableFactoryValidator(factory); + // assert + expect(act).to.throw(expectedError); + }); + }); + }); + describe('collectErrors', () => { + it('reports error thrown by factory function', () => { + // arrange + const errorFromFactory = 'Error from factory function'; + const expectedError = `Error in factory creation: ${errorFromFactory}`; + const factory: FactoryFunction = () => { + throw new Error(errorFromFactory); + }; + const sut = new TestableFactoryValidator(factory); + // act + const errors = [...sut.collectErrors()]; + // assert + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.equal(expectedError); + }); + describe('reports when factory returns falsy values', () => { + const falsyValueTestCases = [ + { + name: '`false` boolean', + value: false, + }, + { + name: 'number zero', + value: 0, + }, + { + name: 'empty string', + value: '', + }, + { + name: 'null', + value: null, + }, + { + name: 'undefined', + value: undefined, + }, + { + name: 'NaN (Not-a-Number)', + value: Number.NaN, + }, + ]; + falsyValueTestCases.forEach(({ name, value }) => { + it(`reports for value: ${name}`, () => { + // arrange + const errorFromFactory = 'Factory resulted in a falsy value'; + const factory: FactoryFunction = () => { + return value as never; + }; + const sut = new TestableFactoryValidator(factory); + // act + const errors = [...sut.collectErrors()]; + // assert + expect(errors).to.have.lengthOf(1); + expect(errors[0]).to.equal(errorFromFactory); + }); + }); + }); + it('does not report when factory returns a truthy value', () => { + // arrange + const factory: FactoryFunction = () => { + return 35; + }; + const sut = new TestableFactoryValidator(factory); + // act + const errors = [...sut.collectErrors()]; + // assert + expect(errors).to.have.lengthOf(0); + }); + it('executes factory for each method call', () => { + // arrange + let forceFalsyValue = false; + const complexFactory: FactoryFunction = () => { + return forceFalsyValue ? undefined : 42; + }; + const sut = new TestableFactoryValidator(complexFactory); + // act + const firstErrors = [...sut.collectErrors()]; + forceFalsyValue = true; + const secondErrors = [...sut.collectErrors()]; + // assert + expect(firstErrors).to.have.lengthOf(0); + expect(secondErrors).to.have.lengthOf(1); + }); + }); +}); + +class TestableFactoryValidator extends FactoryValidator { + public constructor(factory: FactoryFunction) { + super(factory); + } + + public name = 'test'; + + public shouldValidate(): boolean { + return true; + } +} diff --git a/tests/unit/infrastructure/RuntimeSanity/SanityChecks.spec.ts b/tests/unit/infrastructure/RuntimeSanity/SanityChecks.spec.ts index 4d212df8..4e4c3d49 100644 --- a/tests/unit/infrastructure/RuntimeSanity/SanityChecks.spec.ts +++ b/tests/unit/infrastructure/RuntimeSanity/SanityChecks.spec.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks'; -import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/ISanityCheckOptions'; +import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; import { SanityCheckOptionsStub } from '@tests/unit/shared/Stubs/SanityCheckOptionsStub'; -import { ISanityValidator } from '@/infrastructure/RuntimeSanity/ISanityValidator'; +import { ISanityValidator } from '@/infrastructure/RuntimeSanity/Common/ISanityValidator'; import { SanityValidatorStub } from '@tests/unit/shared/Stubs/SanityValidatorStub'; -import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { itEachAbsentCollectionValue, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; describe('SanityChecks', () => { describe('validateRuntimeSanity', () => { @@ -21,15 +21,31 @@ describe('SanityChecks', () => { expect(act).to.throw(expectedError); }); }); - it('throws when validators are empty', () => { - // arrange - const expectedError = 'missing validators'; - const context = new TestContext() - .withValidators([]); - // act - const act = () => context.validateRuntimeSanity(); - // assert - expect(act).to.throw(expectedError); + describe('throws when validators are empty', () => { + itEachAbsentCollectionValue((absentCollection) => { + // arrange + const expectedError = 'missing validators'; + const validators = absentCollection; + const context = new TestContext() + .withValidators(validators); + // act + const act = () => context.validateRuntimeSanity(); + // assert + expect(act).to.throw(expectedError); + }, { excludeUndefined: true }); + }); + describe('throws when single validator is absent', () => { + itEachAbsentObjectValue((absentValue) => { + // arrange + const expectedError = 'missing validator in validators'; + const absentValidator = absentValue; + const context = new TestContext() + .withValidators([new SanityValidatorStub(), absentValidator]); + // act + const act = () => context.validateRuntimeSanity(); + // assert + expect(act).to.throw(expectedError); + }); }); }); @@ -99,6 +115,35 @@ describe('SanityChecks', () => { expect(actualError).to.include(firstError); expect(actualError).to.include(secondError); }); + it('throws with validators name', () => { + // arrange + const validatorWithErrors = 'validator-with-errors'; + const validatorWithNoErrors = 'validator-with-no-errors'; + let actualError = ''; + const context = new TestContext() + .withValidators([ + new SanityValidatorStub() + .withName(validatorWithErrors) + .withShouldValidateResult(true) + .withErrorsResult(['error']), + new SanityValidatorStub() + .withShouldValidateResult(true) + .withErrorsResult([]), + new SanityValidatorStub() + .withShouldValidateResult(true) + .withErrorsResult([]), + ]); + // act + try { + context.validateRuntimeSanity(); + } catch (err) { + actualError = err.toString(); + } + // assert + expect(actualError).to.have.length.above(0); + expect(actualError).to.include(validatorWithErrors); + expect(actualError).to.not.include(validatorWithNoErrors); + }); it('accumulates error messages from validators', () => { // arrange const errorFromFirstValidator = 'first-error'; diff --git a/tests/unit/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts b/tests/unit/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts new file mode 100644 index 00000000..5654d458 --- /dev/null +++ b/tests/unit/infrastructure/RuntimeSanity/Validators/EnvironmentValidator.spec.ts @@ -0,0 +1,7 @@ +import { describe } from 'vitest'; +import { EnvironmentValidator } from '@/infrastructure/RuntimeSanity/Validators/EnvironmentValidator'; +import { itNoErrorsOnCurrentEnvironment } from './ValidatorTestRunner'; + +describe('EnvironmentValidator', () => { + itNoErrorsOnCurrentEnvironment(() => new EnvironmentValidator()); +}); diff --git a/tests/unit/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts b/tests/unit/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts index a280932c..ea286106 100644 --- a/tests/unit/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts +++ b/tests/unit/infrastructure/RuntimeSanity/Validators/MetadataValidator.spec.ts @@ -1,133 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe } from 'vitest'; import { MetadataValidator } from '@/infrastructure/RuntimeSanity/Validators/MetadataValidator'; -import { SanityCheckOptionsStub } from '@tests/unit/shared/Stubs/SanityCheckOptionsStub'; -import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; -import { AppMetadataStub } from '@tests/unit/shared/Stubs/AppMetadataStub'; -import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata'; +import { itNoErrorsOnCurrentEnvironment } from './ValidatorTestRunner'; describe('MetadataValidator', () => { - describe('shouldValidate', () => { - it('returns true when validateMetadata is true', () => { - // arrange - const expectedValue = true; - const options = new SanityCheckOptionsStub() - .withValidateMetadata(true); - const validator = new TestContext() - .createSut(); - // act - const actualValue = validator.shouldValidate(options); - // assert - expect(actualValue).to.equal(expectedValue); - }); - - it('returns false when validateMetadata is false', () => { - // arrange - const expectedValue = false; - const options = new SanityCheckOptionsStub() - .withValidateMetadata(false); - const validator = new TestContext() - .createSut(); - // act - const actualValue = validator.shouldValidate(options); - // assert - expect(actualValue).to.equal(expectedValue); - }); - }); - describe('collectErrors', () => { - describe('yields "missing metadata" if metadata is not provided', () => { - itEachAbsentObjectValue((absentValue) => { - // arrange - const expectedError = 'missing metadata'; - const validator = new TestContext() - .withMetadata(absentValue) - .createSut(); - // act - const errors = [...validator.collectErrors()]; - // assert - expect(errors).to.have.lengthOf(1); - expect(errors[0]).to.equal(expectedError); - }); - }); - it('yields missing keys if metadata has keys without values', () => { - // arrange - const expectedError = 'Metadata keys missing: name, homepageUrl'; - const metadata = new AppMetadataStub() - .witName(undefined) - .withHomepageUrl(undefined); - const validator = new TestContext() - .withMetadata(metadata) - .createSut(); - // act - const errors = [...validator.collectErrors()]; - // assert - expect(errors).to.have.lengthOf(1); - expect(errors[0]).to.equal(expectedError); - }); - it('yields missing keys if metadata has getters instead of properties', () => { - /* - This test may behave differently in unit testing vs. production due to how code - is transformed, especially around class getters and their enumerability during bundling. - */ - // arrange - const expectedError = 'Metadata keys missing: name, homepageUrl'; - const stubWithGetters: Partial = { - get name() { - return undefined; - }, - get homepageUrl() { - return undefined; - }, - }; - const stub: IAppMetadata = { - ...new AppMetadataStub(), - ...stubWithGetters, - }; - const validator = new TestContext() - .withMetadata(stub) - .createSut(); - // act - const errors = [...validator.collectErrors()]; - // assert - expect(errors).to.have.lengthOf(1); - expect(errors[0]).to.equal(expectedError); - }); - it('yields unable to capture metadata if metadata has no getter values', () => { - // arrange - const expectedError = 'Unable to capture metadata key/value pairs'; - const stub = {} as IAppMetadata; - const validator = new TestContext() - .withMetadata(stub) - .createSut(); - // act - const errors = [...validator.collectErrors()]; - // assert - expect(errors).to.have.lengthOf(1); - expect(errors[0]).to.equal(expectedError); - }); - it('does not yield errors if all metadata keys have values', () => { - // arrange - const metadata = new AppMetadataStub(); - const validator = new TestContext() - .withMetadata(metadata) - .createSut(); - // act - const errors = [...validator.collectErrors()]; - // assert - expect(errors).to.have.lengthOf(0); - }); - }); + itNoErrorsOnCurrentEnvironment(() => new MetadataValidator()); }); - -class TestContext { - public metadata: IAppMetadata = new AppMetadataStub(); - - public withMetadata(metadata: IAppMetadata): this { - this.metadata = metadata; - return this; - } - - public createSut(): MetadataValidator { - const mockFactory = () => this.metadata; - return new MetadataValidator(mockFactory); - } -} diff --git a/tests/unit/infrastructure/RuntimeSanity/Validators/ValidatorTestRunner.ts b/tests/unit/infrastructure/RuntimeSanity/Validators/ValidatorTestRunner.ts new file mode 100644 index 00000000..bf51f6a5 --- /dev/null +++ b/tests/unit/infrastructure/RuntimeSanity/Validators/ValidatorTestRunner.ts @@ -0,0 +1,18 @@ +import { it, expect } from 'vitest'; +import { ISanityValidator } from '@/infrastructure/RuntimeSanity/Common/ISanityValidator'; + +export function itNoErrorsOnCurrentEnvironment( + factory: () => ISanityValidator, +) { + if (!factory) { + throw new Error('missing factory'); + } + it('it does report errors on current environment', () => { + // arrange + const validator = factory(); + // act + const errors = [...validator.collectErrors()]; + // assert + expect(errors).to.have.lengthOf(0); + }); +} diff --git a/tests/unit/presentation/components/Shared/Throttle.spec.ts b/tests/unit/presentation/components/Shared/Throttle.spec.ts index c7c67956..bb8e0a09 100644 --- a/tests/unit/presentation/components/Shared/Throttle.spec.ts +++ b/tests/unit/presentation/components/Shared/Throttle.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { throttle, ITimer, TimeoutType } from '@/presentation/components/Shared/Throttle'; import { EventSource } from '@/infrastructure/Events/EventSource'; import { IEventSubscription } from '@/infrastructure/Events/IEventSource'; -import { AbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; +import { getAbsentObjectTestCases, itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests'; describe('throttle', () => { describe('validates parameters', () => { @@ -30,7 +30,7 @@ describe('throttle', () => { value: -2, expectedError: 'negative delay', }, - ...AbsentObjectTestCases.map((testCase) => ({ + ...getAbsentObjectTestCases().map((testCase) => ({ name: `when absent (given ${testCase.valueName})`, value: testCase.absentValue, expectedError: 'missing delay', diff --git a/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts b/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts new file mode 100644 index 00000000..73f0a085 --- /dev/null +++ b/tests/unit/presentation/electron/preload/NodeOsMapper.spec.ts @@ -0,0 +1,58 @@ +import { describe } from 'vitest'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { convertPlatformToOs } from '@/presentation/electron/preload/NodeOsMapper'; + +describe('NodeOsMapper', () => { + describe('convertPlatformToOs', () => { + describe('determines desktop OS', () => { + // arrange + interface IDesktopTestCase { + nodePlatform: NodeJS.Platform; + expectedOs: OperatingSystem; + } + const testCases: readonly IDesktopTestCase[] = [ // https://nodejs.org/api/process.html#process_process_platform + { + nodePlatform: 'aix', + expectedOs: undefined, + }, + { + nodePlatform: 'darwin', + expectedOs: OperatingSystem.macOS, + }, + { + nodePlatform: 'freebsd', + expectedOs: undefined, + }, + { + nodePlatform: 'linux', + expectedOs: OperatingSystem.Linux, + }, + { + nodePlatform: 'openbsd', + expectedOs: undefined, + }, + { + nodePlatform: 'sunos', + expectedOs: undefined, + }, + { + nodePlatform: 'win32', + expectedOs: OperatingSystem.Windows, + }, + ]; + testCases.forEach(({ nodePlatform, expectedOs }) => { + it(nodePlatform, () => { + // act + const actualOs = convertPlatformToOs(nodePlatform); + // assert + expect(actualOs).to.equal(expectedOs, printMessage()); + function printMessage(): string { + return `Expected: "${OperatingSystem[expectedOs]}"\n` + + `Actual: "${OperatingSystem[actualOs]}"\n` + + `Platform: "${nodePlatform}"`; + } + }); + }); + }); + }); +}); diff --git a/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts b/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts new file mode 100644 index 00000000..02d8b8c4 --- /dev/null +++ b/tests/unit/presentation/electron/preload/WindowVariablesProvider.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { provideWindowVariables } from '@/presentation/electron/preload/WindowVariablesProvider'; +import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub'; +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; + +describe('WindowVariablesProvider', () => { + describe('provideWindowVariables', () => { + it('returns expected system', () => { + // arrange + const expectedValue = new SystemOperationsStub(); + // act + const variables = new TestContext() + .withSystem(expectedValue) + .provideWindowVariables(); + // assert + expect(variables.system).to.equal(expectedValue); + }); + it('returns expected os', () => { + // arrange + const expectedValue = OperatingSystem.WindowsPhone; + // act + const variables = new TestContext() + .withOs(expectedValue) + .provideWindowVariables(); + // assert + expect(variables.os).to.equal(expectedValue); + }); + it('`isDesktop` is true', () => { + // arrange + const expectedValue = true; + // act + const variables = new TestContext() + .provideWindowVariables(); + // assert + expect(variables.isDesktop).to.equal(expectedValue); + }); + }); +}); + +class TestContext { + private system: ISystemOperations = new SystemOperationsStub(); + + private os: OperatingSystem = OperatingSystem.Android; + + public withSystem(system: ISystemOperations): this { + this.system = system; + return this; + } + + public withOs(os: OperatingSystem): this { + this.os = os; + return this; + } + + public provideWindowVariables() { + return provideWindowVariables( + () => this.system, + () => this.os, + ); + } +} diff --git a/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts b/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts new file mode 100644 index 00000000..c3941e06 --- /dev/null +++ b/tests/unit/shared/Stubs/BrowserOsDetectorStub.ts @@ -0,0 +1,8 @@ +import { OperatingSystem } from '@/domain/OperatingSystem'; +import { IBrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/IBrowserOsDetector'; + +export class BrowserOsDetectorStub implements IBrowserOsDetector { + public detect(): OperatingSystem { + return OperatingSystem.BlackBerryTabletOS; + } +} diff --git a/tests/unit/shared/Stubs/CommandOpsStub.ts b/tests/unit/shared/Stubs/CommandOpsStub.ts new file mode 100644 index 00000000..64c6f5b5 --- /dev/null +++ b/tests/unit/shared/Stubs/CommandOpsStub.ts @@ -0,0 +1,13 @@ +import { ICommandOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export class CommandOpsStub + extends StubWithObservableMethodCalls + implements ICommandOps { + public execute(command: string): void { + this.registerMethodCall({ + methodName: 'execute', + args: [command], + }); + } +} diff --git a/tests/unit/shared/Stubs/EnvironmentStub.ts b/tests/unit/shared/Stubs/EnvironmentStub.ts index 78cdd98c..aecdf2d7 100644 --- a/tests/unit/shared/Stubs/EnvironmentStub.ts +++ b/tests/unit/shared/Stubs/EnvironmentStub.ts @@ -1,13 +1,22 @@ -import { IEnvironment } from '@/application/Environment/IEnvironment'; +import { IEnvironment } from '@/infrastructure/Environment/IEnvironment'; import { OperatingSystem } from '@/domain/OperatingSystem'; +import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { SystemOperationsStub } from './SystemOperationsStub'; export class EnvironmentStub implements IEnvironment { public isDesktop = true; public os = OperatingSystem.Windows; - public withOs(os: OperatingSystem): EnvironmentStub { + public system: ISystemOperations = new SystemOperationsStub(); + + public withOs(os: OperatingSystem): this { this.os = os; return this; } + + public withSystemOperations(system: ISystemOperations): this { + this.system = system; + return this; + } } diff --git a/tests/unit/shared/Stubs/FileSystemOpsStub.ts b/tests/unit/shared/Stubs/FileSystemOpsStub.ts new file mode 100644 index 00000000..80d808b2 --- /dev/null +++ b/tests/unit/shared/Stubs/FileSystemOpsStub.ts @@ -0,0 +1,30 @@ +import { IFileSystemOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export class FileSystemOpsStub + extends StubWithObservableMethodCalls + implements IFileSystemOps { + public setFilePermissions(filePath: string, mode: string | number): Promise { + this.registerMethodCall({ + methodName: 'setFilePermissions', + args: [filePath, mode], + }); + return Promise.resolve(); + } + + public createDirectory(directoryPath: string, isRecursive?: boolean): Promise { + this.registerMethodCall({ + methodName: 'createDirectory', + args: [directoryPath, isRecursive], + }); + return Promise.resolve(directoryPath); + } + + public writeToFile(filePath: string, data: string): Promise { + this.registerMethodCall({ + methodName: 'writeToFile', + args: [filePath, data], + }); + return Promise.resolve(); + } +} diff --git a/tests/unit/shared/Stubs/LocationOpsStub.ts b/tests/unit/shared/Stubs/LocationOpsStub.ts new file mode 100644 index 00000000..ee49c87b --- /dev/null +++ b/tests/unit/shared/Stubs/LocationOpsStub.ts @@ -0,0 +1,40 @@ +import { ILocationOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export class LocationOpsStub + extends StubWithObservableMethodCalls + implements ILocationOps { + private sequence = new Array(); + + private scenarios = new Map(); + + public withJoinResult(returnValue: string, ...paths: string[]): this { + this.scenarios.set(LocationOpsStub.getScenarioKey(paths), returnValue); + return this; + } + + public withJoinResultSequence(...valuesToReturn: string[]): this { + this.sequence.push(...valuesToReturn); + this.sequence.reverse(); + return this; + } + + public combinePaths(...pathSegments: string[]): string { + this.registerMethodCall({ + methodName: 'combinePaths', + args: pathSegments, + }); + if (this.sequence.length > 0) { + return this.sequence.pop(); + } + const key = LocationOpsStub.getScenarioKey(pathSegments); + if (!this.scenarios.has(key)) { + return pathSegments.join('/PATH-SEGMENT-SEPARATOR/'); + } + return this.scenarios.get(key); + } + + private static getScenarioKey(paths: string[]): string { + return paths.join('|'); + } +} diff --git a/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts b/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts new file mode 100644 index 00000000..25a47fe4 --- /dev/null +++ b/tests/unit/shared/Stubs/OperatingSystemOpsStub.ts @@ -0,0 +1,21 @@ +import { IOperatingSystemOps } from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { StubWithObservableMethodCalls } from './StubWithObservableMethodCalls'; + +export class OperatingSystemOpsStub + extends StubWithObservableMethodCalls + implements IOperatingSystemOps { + private temporaryDirectory = '/stub-temp-dir/'; + + public withTemporaryDirectoryResult(directory: string): this { + this.temporaryDirectory = directory; + return this; + } + + public getTempDirectory(): string { + this.registerMethodCall({ + methodName: 'getTempDirectory', + args: [], + }); + return this.temporaryDirectory; + } +} diff --git a/tests/unit/shared/Stubs/SanityCheckOptionsStub.ts b/tests/unit/shared/Stubs/SanityCheckOptionsStub.ts index 97d0f233..a1bd07e1 100644 --- a/tests/unit/shared/Stubs/SanityCheckOptionsStub.ts +++ b/tests/unit/shared/Stubs/SanityCheckOptionsStub.ts @@ -1,10 +1,17 @@ -import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/ISanityCheckOptions'; +import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; export class SanityCheckOptionsStub implements ISanityCheckOptions { + public validateEnvironment = false; + public validateMetadata = false; public withValidateMetadata(value: boolean): this { this.validateMetadata = value; return this; } + + public withValidateEnvironment(value: boolean): this { + this.validateEnvironment = value; + return this; + } } diff --git a/tests/unit/shared/Stubs/SanityValidatorStub.ts b/tests/unit/shared/Stubs/SanityValidatorStub.ts index 3ced2e6b..323ae4fe 100644 --- a/tests/unit/shared/Stubs/SanityValidatorStub.ts +++ b/tests/unit/shared/Stubs/SanityValidatorStub.ts @@ -1,9 +1,11 @@ -import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/ISanityCheckOptions'; -import { ISanityValidator } from '@/infrastructure/RuntimeSanity/ISanityValidator'; +import { ISanityCheckOptions } from '@/infrastructure/RuntimeSanity/Common/ISanityCheckOptions'; +import { ISanityValidator } from '@/infrastructure/RuntimeSanity/Common/ISanityValidator'; export class SanityValidatorStub implements ISanityValidator { public shouldValidateArgs = new Array(); + public name = 'sanity-validator-stub'; + private errors: readonly string[] = []; private shouldValidateResult = true; @@ -17,6 +19,11 @@ export class SanityValidatorStub implements ISanityValidator { return this.errors; } + public withName(name: string): this { + this.name = name; + return this; + } + public withErrorsResult(errors: readonly string[]): this { this.errors = errors; return this; diff --git a/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts b/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts new file mode 100644 index 00000000..fccd6b91 --- /dev/null +++ b/tests/unit/shared/Stubs/StubWithObservableMethodCalls.ts @@ -0,0 +1,25 @@ +import { EventSource } from '@/infrastructure/Events/EventSource'; +import { IEventSource } from '@/infrastructure/Events/IEventSource'; +import { FunctionKeys } from '@/TypeHelpers'; + +export abstract class StubWithObservableMethodCalls { + public readonly callHistory = new Array>(); + + public get methodCalls(): IEventSource> { + return this.notifiableMethodCalls; + } + + private readonly notifiableMethodCalls = new EventSource>(); + + protected registerMethodCall(name: MethodCall) { + this.callHistory.push(name); + this.notifiableMethodCalls.notify(name); + } +} + +type MethodCall = { + [K in FunctionKeys]: { + methodName: K; + args: T[K] extends (...args: infer A) => unknown ? A : never; + } +}[FunctionKeys]; diff --git a/tests/unit/shared/Stubs/SystemOperationsStub.ts b/tests/unit/shared/Stubs/SystemOperationsStub.ts new file mode 100644 index 00000000..a71cdcfb --- /dev/null +++ b/tests/unit/shared/Stubs/SystemOperationsStub.ts @@ -0,0 +1,41 @@ +import { + ICommandOps, + IFileSystemOps, + IOperatingSystemOps, + ILocationOps, + ISystemOperations, +} from '@/infrastructure/Environment/SystemOperations/ISystemOperations'; +import { CommandOpsStub } from './CommandOpsStub'; +import { FileSystemOpsStub } from './FileSystemOpsStub'; +import { LocationOpsStub } from './LocationOpsStub'; +import { OperatingSystemOpsStub } from './OperatingSystemOpsStub'; + +export class SystemOperationsStub implements ISystemOperations { + public operatingSystem: IOperatingSystemOps = new OperatingSystemOpsStub(); + + public location: ILocationOps = new LocationOpsStub(); + + public fileSystem: IFileSystemOps = new FileSystemOpsStub(); + + public command: ICommandOps = new CommandOpsStub(); + + public withOperatingSystem(operatingSystemOps: IOperatingSystemOps): this { + this.operatingSystem = operatingSystemOps; + return this; + } + + public withLocation(location: ILocationOps): this { + this.location = location; + return this; + } + + public withFileSystem(fileSystem: IFileSystemOps): this { + this.fileSystem = fileSystem; + return this; + } + + public withCommand(command: ICommandOps): this { + this.command = command; + return this; + } +} diff --git a/tests/unit/shared/TestCases/AbsentTests.ts b/tests/unit/shared/TestCases/AbsentTests.ts index 624593a5..689bf13d 100644 --- a/tests/unit/shared/TestCases/AbsentTests.ts +++ b/tests/unit/shared/TestCases/AbsentTests.ts @@ -1,17 +1,24 @@ import { it } from 'vitest'; -export function itEachAbsentStringValue(runner: (absentValue: string) => void): void { - itEachAbsentTestCase(AbsentStringTestCases, runner); +export function itEachAbsentStringValue( + runner: (absentValue: string) => void, + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, +): void { + itEachAbsentTestCase(getAbsentStringTestCases(options), runner); } export function itEachAbsentObjectValue( runner: (absentValue: AbsentObjectType) => void, + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, ): void { - itEachAbsentTestCase(AbsentObjectTestCases, runner); + itEachAbsentTestCase(getAbsentObjectTestCases(options), runner); } -export function itEachAbsentCollectionValue(runner: (absentValue: []) => void): void { - itEachAbsentTestCase(getAbsentCollectionTestCases(), runner); +export function itEachAbsentCollectionValue( + runner: (absentValue: []) => void, + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, +): void { + itEachAbsentTestCase(getAbsentCollectionTestCases(options), runner); } export function itEachAbsentTestCase( @@ -25,28 +32,40 @@ export function itEachAbsentTestCase( } } -export const AbsentObjectTestCases: readonly IAbsentTestCase[] = [ - { - valueName: 'undefined', - absentValue: undefined, - }, - { - valueName: 'null', - absentValue: null, - }, -]; - -export const AbsentStringTestCases: readonly IAbsentStringCase[] = [ - { - valueName: 'empty', - absentValue: '', - }, - ...AbsentObjectTestCases, -]; - -export function getAbsentCollectionTestCases(): readonly IAbsentCollectionCase[] { +export function getAbsentObjectTestCases( + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, +): IAbsentTestCase[] { return [ - ...AbsentObjectTestCases, + { + valueName: 'null', + absentValue: null, + }, + ...(options.excludeUndefined ? [] : [ + { + valueName: 'undefined', + absentValue: undefined, + }, + ]), + ]; +} + +export function getAbsentStringTestCases( + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, +): IAbsentStringCase[] { + return [ + { + valueName: 'empty', + absentValue: '', + }, + ...getAbsentObjectTestCases(options), + ]; +} + +export function getAbsentCollectionTestCases( + options: IAbsentTestCaseOptions = DefaultAbsentTestCaseOptions, +): readonly IAbsentCollectionCase[] { + return [ + ...getAbsentObjectTestCases(options), { valueName: 'empty', absentValue: new Array(), @@ -54,6 +73,14 @@ export function getAbsentCollectionTestCases(): readonly IAbsentCollectionCas ]; } +const DefaultAbsentTestCaseOptions: IAbsentTestCaseOptions = { + excludeUndefined: false, +}; + +interface IAbsentTestCaseOptions { + readonly excludeUndefined: boolean; +} + type AbsentObjectType = undefined | null; interface IAbsentTestCase { diff --git a/vite.config.ts b/vite.config.ts index c7d249ae..dbc6044a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,6 @@ import { getAliasesFromTsConfig, getClientEnvironmentVariables, getSelfDirectory const WEB_DIRECTORY = resolve(getSelfDirectoryAbsolutePath(), 'src/presentation'); const TEST_INITIALIZATION_FILE = resolve(getSelfDirectoryAbsolutePath(), 'tests/shared/bootstrap/setup.ts'); -const NODE_CORE_MODULES = ['os', 'child_process', 'fs', 'path']; export function createVueConfig(options?: { readonly supportLegacyBrowsers: boolean, @@ -33,14 +32,6 @@ export function createVueConfig(options?: { ...getAliasesFromTsConfig(), }, }, - build: { - rollupOptions: { - // Ensure Node core modules are externalized and don't trigger warnings in browser builds - external: { - ...NODE_CORE_MODULES, - }, - }, - }, server: { port: 3169, },