Improve desktop runtime execution tests

Test improvements:

- Capture titles for all macOS windows, not just the frontmost.
- Incorporate missing application log files.
- Improve log clarity with enriched context.
- Improve application termination on macOS by reducing grace period.
- Ensure complete application termination on macOS.
- Validate Vue application loading through an initial log.
- Support ignoring environment-specific `stderr` errors.
- Do not fail the test if working directory cannot be deleted.
- Use retry pattern when installing dependencies due to network errors.

Refactorings:

- Migrate the test code to TypeScript.
- Replace deprecated `rmdir` with `rm` for error-resistant directory
  removal.
- Improve sanity checking by shifting from App.vue to Vue bootstrapper.
- Centralize environment variable management with `EnvironmentVariables`
  construct.
- Rename infrastructure/Environment to RuntimeEnvironment for clarity.
- Isolate WindowVariables and SystemOperations from RuntimeEnvironment.
- Inject logging via preloader.
- Correct mislabeled RuntimeSanity tests.

Configuration:

- Introduce `npm run check:desktop` for simplified execution.
- Omit `console.log` override due to `nodeIntegration` restrictions and
  reveal logging functionality using context-bridging.
This commit is contained in:
undergroundwires
2023-08-29 16:30:00 +02:00
parent 35be05df20
commit ad0576a752
146 changed files with 2418 additions and 1186 deletions

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { EnvironmentStub } from '@tests/unit/shared/Stubs/EnvironmentStub';
import { RuntimeEnvironmentStub } from '@tests/unit/shared/Stubs/RuntimeEnvironmentStub';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { CodeRunner } from '@/infrastructure/CodeRunner';
import { expectThrowsAsync } from '@tests/unit/shared/Assertions/ExpectThrowsAsync';
@@ -8,23 +8,10 @@ import { OperatingSystemOpsStub } from '@tests/unit/shared/Stubs/OperatingSystem
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 { IFileSystemOps, ISystemOperations } from '@/infrastructure/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
@@ -222,10 +209,9 @@ class TestContext {
private systemOperations: ISystemOperations = new SystemOperationsStub();
public async runCode(): Promise<void> {
const environment = new EnvironmentStub()
.withOs(this.os)
.withSystemOperations(this.systemOperations);
const runner = new CodeRunner(environment);
const environment = new RuntimeEnvironmentStub()
.withOs(this.os);
const runner = new CodeRunner(this.systemOperations, environment);
await runner.runCode(this.code, this.folderName, this.fileExtension);
}

View File

@@ -1,160 +0,0 @@
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();
});
});
});

View File

@@ -0,0 +1,53 @@
import {
describe,
} from 'vitest';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
import { EnvironmentVariablesFactory, EnvironmentVariablesValidator } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { ViteEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentVariables';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
describe('EnvironmentVariablesFactory', () => {
describe('instance', () => {
itIsSingleton({
getter: () => EnvironmentVariablesFactory.Current.instance,
expectedType: ViteEnvironmentVariables,
});
});
it('creates the correct type', () => {
// arrange
const sut = new TestableEnvironmentVariablesFactory();
// act
const metadata = sut.instance;
// assert
expect(metadata).to.be.instanceOf(ViteEnvironmentVariables);
});
it('validates its instance', () => {
// arrange
let validatedInstance: IEnvironmentVariables;
const validatorMock = (instanceToValidate: IEnvironmentVariables) => {
validatedInstance = instanceToValidate;
};
// act
const sut = new TestableEnvironmentVariablesFactory(validatorMock);
const actualInstance = sut.instance;
// assert
expect(actualInstance).to.equal(validatedInstance);
});
it('throws error if validator fails', () => {
// arrange
const expectedError = 'validator failed';
const failingValidator = () => {
throw new Error(expectedError);
};
// act
const act = () => new TestableEnvironmentVariablesFactory(failingValidator);
// assert
expect(act).to.throw(expectedError);
});
});
class TestableEnvironmentVariablesFactory extends EnvironmentVariablesFactory {
public constructor(validator: EnvironmentVariablesValidator = () => { /* NO OP */ }) {
super(validator);
}
}

View File

@@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub';
import { validateEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesValidator';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
describe('EnvironmentVariablesValidator', () => {
it('does not throw if all environment keys have values', () => {
// arrange
const environment = new EnvironmentVariablesStub();
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.not.throw();
});
it('does not throw if a boolean key has false value', () => {
// arrange
const environmentWithFalseBoolean: Partial<IEnvironmentVariables> = {
isNonProduction: false,
};
const environment: IEnvironmentVariables = {
...new EnvironmentVariablesStub(),
...environmentWithFalseBoolean,
};
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.not.throw();
});
describe('throws as expected', () => {
describe('"missing environment" if environment is not provided', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing environment';
const environment = absentValue;
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.throw(expectedError);
});
});
it('"missing keys" if environment has properties with missing values', () => {
// arrange
const expectedError = 'Environment keys missing: name, homepageUrl';
const missingData: Partial<IEnvironmentVariables> = {
name: undefined,
homepageUrl: undefined,
};
const environment: IEnvironmentVariables = {
...new EnvironmentVariablesStub(),
...missingData,
};
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.throw(expectedError);
});
it('"missing keys" if environment has getters with missing values', () => {
// arrange
const expectedError = 'Environment keys missing: name, homepageUrl';
const stubWithGetters: Partial<IEnvironmentVariables> = {
get name() {
return undefined;
},
get homepageUrl() {
return undefined;
},
};
const environment: IEnvironmentVariables = {
...new EnvironmentVariablesStub(),
...stubWithGetters,
};
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.throw(expectedError);
});
it('throws "unable to capture" if environment has no getters or properties', () => {
// arrange
const expectedError = 'Unable to capture key/value pairs';
const environment = {} as IEnvironmentVariables;
// act
const act = () => validateEnvironmentVariables(environment);
// assert
expect(act).to.throw(expectedError);
});
});
});

View File

@@ -1,5 +1,5 @@
import { expect, describe, it } from 'vitest';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/Metadata/Vite/ViteEnvironmentKeys';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentKeys';
describe('VITE_ENVIRONMENT_KEYS', () => {
describe('each key should have a non-empty string', () => {

View File

@@ -1,11 +1,12 @@
import {
describe, beforeEach, afterEach, expect,
} from 'vitest';
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/Metadata/Vite/ViteEnvironmentKeys';
import { VITE_ENVIRONMENT_KEYS } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentKeys';
import { PropertyKeys } from '@/TypeHelpers';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { ViteEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/Vite/ViteEnvironmentVariables';
describe('ViteAppMetadata', () => {
describe('ViteEnvironmentVariables', () => {
describe('reads values from import.meta.env', () => {
let originalMetaEnv;
beforeEach(() => {
@@ -15,13 +16,15 @@ describe('ViteAppMetadata', () => {
Object.assign(import.meta.env, originalMetaEnv);
});
interface ITestCase {
readonly getActualValue: (sut: ViteAppMetadata) => string;
interface ITestCase<T> {
readonly getActualValue: (sut: IEnvironmentVariables) => T;
readonly environmentVariable: typeof VITE_ENVIRONMENT_KEYS[
keyof typeof VITE_ENVIRONMENT_KEYS];
readonly expected: string;
readonly expected: T;
}
const testCases: { readonly [K in PropertyKeys<ViteAppMetadata>]: ITestCase } = {
const testCases: {
readonly [K in PropertyKeys<IEnvironmentVariables>]: ITestCase<string | boolean>;
} = {
name: {
environmentVariable: VITE_ENVIRONMENT_KEYS.NAME,
expected: 'expected-name',
@@ -47,6 +50,11 @@ describe('ViteAppMetadata', () => {
expected: 'expected-homepageUrl',
getActualValue: (sut) => sut.homepageUrl,
},
isNonProduction: {
environmentVariable: VITE_ENVIRONMENT_KEYS.DEV,
expected: false,
getActualValue: (sut) => sut.isNonProduction,
},
};
Object.values(testCases).forEach(({ environmentVariable, expected, getActualValue }) => {
it(`should correctly get the value of ${environmentVariable}`, () => {
@@ -54,7 +62,7 @@ describe('ViteAppMetadata', () => {
import.meta.env[environmentVariable] = expected;
// act
const sut = new ViteAppMetadata();
const sut = new ViteEnvironmentVariables();
const actualValue = getActualValue(sut);
// assert

View File

@@ -0,0 +1,46 @@
import { describe, expect } from 'vitest';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('ConsoleLogger', () => {
describe('throws if console is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing console';
const console = absentValue;
// act
const act = () => new ConsoleLogger(console);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
describe('methods log the provided params', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedParams = testParameters;
const consoleMock = new MockConsole();
const logger = new ConsoleLogger(consoleMock);
// act
logger[functionName](...expectedParams);
// assert
expect(consoleMock.callHistory).to.have.lengthOf(1);
expect(consoleMock.callHistory[0].methodName).to.equal(functionName);
expect(consoleMock.callHistory[0].args).to.deep.equal(expectedParams);
});
});
});
class MockConsole
extends StubWithObservableMethodCalls<Partial<Console>>
implements Partial<Console> {
public info(...args: unknown[]) {
this.registerMethodCall({
methodName: 'info',
args,
});
}
}

View File

@@ -0,0 +1,45 @@
import { describe, expect } from 'vitest';
import { ElectronLog } from 'electron-log';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('ElectronLogger', () => {
describe('throws if logger is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing logger';
const electronLog = absentValue;
// act
const act = () => createElectronLogger(electronLog);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedParams = testParameters;
const electronLogMock = new MockElectronLog();
const logger = createElectronLogger(electronLogMock);
// act
logger[functionName](...expectedParams);
// assert
expect(electronLogMock.callHistory).to.have.lengthOf(1);
expect(electronLogMock.callHistory[0].methodName).to.equal(functionName);
expect(electronLogMock.callHistory[0].args).to.deep.equal(expectedParams);
});
});
class MockElectronLog
extends StubWithObservableMethodCalls<Partial<ElectronLog>>
implements Partial<ElectronLog> {
public info(...args: unknown[]) {
this.registerMethodCall({
methodName: 'info',
args,
});
}
}

View File

@@ -0,0 +1,21 @@
import { it } from 'vitest';
import { FunctionKeys } from '@/TypeHelpers';
import { ILogger } from '@/infrastructure/Log/ILogger';
export function itEachLoggingMethod(
handler: (
functionName: keyof ILogger,
testParameters?: unknown[]
) => void,
) {
const testParameters = ['test', 123, { some: 'object' }];
const loggerMethods: Array<FunctionKeys<ILogger>> = [
'info',
];
loggerMethods
.forEach((functionKey) => {
it(functionKey, () => {
handler(functionKey, testParameters);
});
});
}

View File

@@ -0,0 +1,20 @@
import { describe, expect } from 'vitest';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('NoopLogger', () => {
describe('methods do not throw', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const randomParams = testParameters;
const logger: ILogger = new NoopLogger();
// act
const act = () => logger[functionName](...randomParams);
// assert
expect(act).to.not.throw();
});
});
});

View File

@@ -0,0 +1,50 @@
import { describe } from 'vitest';
import { LoggerStub } from '@tests/unit/shared/Stubs/LoggerStub';
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('WindowInjectedLogger', () => {
describe('throws if log is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing log';
const windowVariables = new WindowVariablesStub()
.withLog(absentValue);
// act
const act = () => new WindowInjectedLogger(windowVariables);
// assert
expect(act).to.throw(expectedError);
});
});
describe('throws if window is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing window';
const windowVariables = absentValue;
// act
const act = () => new WindowInjectedLogger(windowVariables);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
describe('methods log the provided params', () => {
itEachLoggingMethod((functionName) => {
// arrange
const expectedParams = ['test', 123, { some: 'object' }];
const loggerMock = new LoggerStub();
const windowVariables = new WindowVariablesStub()
.withLog(loggerMock);
const logger = new WindowInjectedLogger(windowVariables);
// act
logger[functionName](...expectedParams);
// assert
expect(loggerMock.callHistory).to.have.lengthOf(1);
expect(loggerMock.callHistory[0].methodName).to.equal(functionName);
expect(loggerMock.callHistory[0].args).to.deep.equal(expectedParams);
});
});
});

View File

@@ -1,53 +0,0 @@
import {
describe,
} from 'vitest';
import { itIsSingleton } from '@tests/unit/shared/TestCases/SingletonTests';
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.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);
});
});

View File

@@ -1,74 +0,0 @@
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<IAppMetadata> = {
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<IAppMetadata> = {
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);
});
});
});

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/BrowserOsDetector';
import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { BrowserOsTestCases } from './BrowserOsTestCases';

View File

@@ -1,13 +1,13 @@
import { describe, it, expect } from 'vitest';
import { IBrowserOsDetector } from '@/infrastructure/Environment/BrowserOs/IBrowserOsDetector';
import { IBrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Environment, WindowValidator } from '@/infrastructure/Environment/Environment';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { WindowVariables } from '@/infrastructure/Environment/WindowVariables';
import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub';
describe('Environment', () => {
describe('RuntimeEnvironment', () => {
describe('ctor', () => {
describe('throws if window is absent', () => {
itEachAbsentObjectValue((absentValue) => {
@@ -135,63 +135,21 @@ describe('Environment', () => {
});
});
});
describe('system', () => {
it('fetches system operations from window', () => {
// arrange
const expectedSystem = new SystemOperationsStub();
const windowWithSystem = {
system: expectedSystem,
};
// act
const sut = createEnvironment({
window: windowWithSystem,
describe('isNonProduction', () => {
[true, false].forEach((value) => {
it(`sets ${value} from environment variables`, () => {
// arrange
const expectedValue = value;
const environment = new EnvironmentVariablesStub()
.withIsNonProduction(expectedValue);
// act
const sut = createEnvironment({
environmentVariables: environment,
});
// assert
const actualValue = sut.isNonProduction;
expect(actualValue).to.equal(expectedValue);
});
// 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<WindowVariables> = {};
let actualVariables: Partial<WindowVariables>;
const mockValidator: WindowValidator = (variables) => {
actualVariables = variables;
};
// act
createEnvironment({
window: expectedVariables,
windowValidator: mockValidator,
});
// assert
expect(actualVariables).to.equal(expectedVariables);
});
});
});
@@ -199,21 +157,21 @@ describe('Environment', () => {
interface EnvironmentOptions {
window: Partial<Window>;
browserOsDetector?: IBrowserOsDetector;
windowValidator?: WindowValidator;
environmentVariables?: IEnvironmentVariables;
}
function createEnvironment(options: Partial<EnvironmentOptions> = {}): TestableEnvironment {
function createEnvironment(options: Partial<EnvironmentOptions> = {}): TestableRuntimeEnvironment {
const defaultOptions: EnvironmentOptions = {
window: {},
browserOsDetector: new BrowserOsDetectorStub(),
windowValidator: () => { /* NO OP */ },
environmentVariables: new EnvironmentVariablesStub(),
};
return new TestableEnvironment({ ...defaultOptions, ...options });
return new TestableRuntimeEnvironment({ ...defaultOptions, ...options });
}
class TestableEnvironment extends Environment {
class TestableRuntimeEnvironment extends RuntimeEnvironment {
public constructor(options: EnvironmentOptions) {
super(options.window, options.browserOsDetector, options.windowValidator);
super(options.window, options.environmentVariables, options.browserOsDetector);
}
}

View File

@@ -0,0 +1,226 @@
import { describe, it, expect } from 'vitest';
import { validateWindowVariables } from '@/infrastructure/WindowVariables/WindowVariablesValidator';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
import { getAbsentObjectTestCases } from '@tests/unit/shared/TestCases/AbsentTests';
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
describe('WindowVariablesValidator', () => {
describe('validateWindowVariables', () => {
describe('validates window type', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const expectedError = 'window is not an object';
const window: Partial<WindowVariables> = invalidObjectValue;
// act
const act = () => validateWindowVariables(window);
// assert
expect(act).to.throw(expectedError);
});
});
describe('property validations', () => {
it('throws an error with a description of all invalid properties', () => {
// arrange
const invalidOs = 'invalid' as unknown as OperatingSystem;
const invalidIsDesktop = 'not a boolean' as unknown as boolean;
const expectedError = getExpectedError(
{
name: 'os',
object: invalidOs,
},
{
name: 'isDesktop',
object: invalidIsDesktop,
},
);
const input = new WindowVariablesStub()
.withOs(invalidOs)
.withIsDesktop(invalidIsDesktop);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.throw(expectedError);
});
describe('`os` property', () => {
it('throws an error when os is not a number', () => {
// arrange
const invalidOs = 'Linux' as unknown as OperatingSystem;
const expectedError = getExpectedError(
{
name: 'os',
object: invalidOs,
},
);
const input = new WindowVariablesStub()
.withOs(invalidOs);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.throw(expectedError);
});
it('throws an error for an invalid numeric os value', () => {
// arrange
const invalidOs = Number.MAX_SAFE_INTEGER;
const expectedError = getExpectedError(
{
name: 'os',
object: invalidOs,
},
);
const input = new WindowVariablesStub()
.withOs(invalidOs);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.throw(expectedError);
});
it('does not throw for a missing os value', () => {
// arrange
const input = new WindowVariablesStub()
.withIsDesktop(true)
.withOs(undefined);
// 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 systemObject = undefined;
const expectedError = getExpectedError(
{
name: 'system',
object: systemObject,
},
);
const input = new WindowVariablesStub()
.withIsDesktop(true)
.withSystem(systemObject);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.throw(expectedError);
});
it('does not throw when isDesktop is true with a valid system object', () => {
// arrange
const validSystem = new SystemOperationsStub();
const input = new WindowVariablesStub()
.withIsDesktop(true)
.withSystem(validSystem);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.not.throw();
});
it('does not throw when isDesktop is false without a system object', () => {
// arrange
const absentSystem = undefined;
const input = new WindowVariablesStub()
.withIsDesktop(false)
.withSystem(absentSystem);
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.not.throw();
});
});
describe('`system` property', () => {
expectObjectOnDesktop('system');
});
describe('`log` property', () => {
expectObjectOnDesktop('log');
});
});
it('does not throw for a valid object', () => {
const input = new WindowVariablesStub();
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.not.throw();
});
});
});
function expectObjectOnDesktop<T>(key: keyof WindowVariables) {
describe('validates object type on desktop', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const invalidObject = invalidObjectValue as T;
const expectedError = getExpectedError({
name: key,
object: invalidObject,
});
const input: WindowVariables = {
...new WindowVariablesStub(),
isDesktop: true,
[key]: invalidObject,
};
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.throw(expectedError);
});
});
describe('does not object type when not on desktop', () => {
itEachInvalidObjectValue((invalidObjectValue) => {
// arrange
const invalidObject = invalidObjectValue as T;
const input: WindowVariables = {
...new WindowVariablesStub(),
isDesktop: undefined,
[key]: invalidObject,
};
// act
const act = () => validateWindowVariables(input);
// assert
expect(act).to.not.throw();
});
});
}
function itEachInvalidObjectValue<T>(runner: (invalidObjectValue: T) => void) {
const testCases: Array<{
readonly name: string;
readonly value: T;
}> = [
{
name: 'given string',
value: 'invalid object' as unknown as T,
},
{
name: 'given array of objects',
value: [{}, {}] as unknown as T,
},
...getAbsentObjectTestCases().map((testCase) => ({
name: `given absent: ${testCase.valueName}`,
value: testCase.absentValue as unknown as T,
})),
];
testCases.forEach((testCase) => {
it(testCase.name, () => {
runner(testCase.value);
});
});
}
function getExpectedError(...unexpectedObjects: Array<{
readonly name: keyof WindowVariables;
readonly object: unknown;
}>) {
const errors = unexpectedObjects
.map(({ name, object }) => `Unexpected ${name} (${typeof object})`);
return errors.join('\n');
}

View File

@@ -1,7 +0,0 @@
import { describe } from 'vitest';
import { EnvironmentValidator } from '@/infrastructure/RuntimeSanity/Validators/EnvironmentValidator';
import { itNoErrorsOnCurrentEnvironment } from './ValidatorTestRunner';
describe('EnvironmentValidator', () => {
itNoErrorsOnCurrentEnvironment(() => new EnvironmentValidator());
});

View File

@@ -0,0 +1,17 @@
import { describe } from 'vitest';
import { EnvironmentVariablesValidator } from '@/infrastructure/RuntimeSanity/Validators/EnvironmentVariablesValidator';
import { FactoryFunction } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator';
import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { runFactoryValidatorTests } from './FactoryValidatorConcreteTestRunner';
describe('EnvironmentVariablesValidator', () => {
runFactoryValidatorTests({
createValidator: (
factory?: FactoryFunction<IEnvironmentVariables>,
) => new EnvironmentVariablesValidator(factory),
enablingOptionProperty: 'validateEnvironmentVariables',
factoryFunctionStub: () => new EnvironmentVariablesStub(),
expectedValidatorName: 'environment variables',
});
});

View File

@@ -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<T> {
createValidator: (factory?: FactoryFunction<T>) => FactoryValidator<T>;
enablingOptionProperty: PropertyKeys<ISanityCheckOptions>;
factoryFunctionStub: FactoryFunction<T>;
expectedValidatorName: string;
}
export function runFactoryValidatorTests<T>(
testOptions: ITestOptions<T>,
) {
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);
});
});
}

View File

@@ -1,7 +0,0 @@
import { describe } from 'vitest';
import { MetadataValidator } from '@/infrastructure/RuntimeSanity/Validators/MetadataValidator';
import { itNoErrorsOnCurrentEnvironment } from './ValidatorTestRunner';
describe('MetadataValidator', () => {
itNoErrorsOnCurrentEnvironment(() => new MetadataValidator());
});

View File

@@ -1,18 +0,0 @@
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);
});
}

View File

@@ -0,0 +1,17 @@
import { describe } from 'vitest';
import { WindowVariablesValidator } from '@/infrastructure/RuntimeSanity/Validators/WindowVariablesValidator';
import { FactoryFunction } from '@/infrastructure/RuntimeSanity/Common/FactoryValidator';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { WindowVariablesStub } from '@tests/unit/shared/Stubs/WindowVariablesStub';
import { runFactoryValidatorTests } from './FactoryValidatorConcreteTestRunner';
describe('WindowVariablesValidator', () => {
runFactoryValidatorTests({
createValidator: (
factory?: FactoryFunction<WindowVariables>,
) => new WindowVariablesValidator(factory),
enablingOptionProperty: 'validateWindowVariables',
factoryFunctionStub: () => new WindowVariablesStub(),
expectedValidatorName: 'window variables',
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { getWindowInjectedSystemOperations } from '@/infrastructure/SystemOperations/WindowInjectedSystemOperations';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { SystemOperationsStub } from '@tests/unit/shared/Stubs/SystemOperationsStub';
describe('WindowInjectedSystemOperations', () => {
describe('getWindowInjectedSystemOperations', () => {
describe('throws if window is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing window';
const window: WindowVariables = absentValue;
// act
const act = () => getWindowInjectedSystemOperations(window);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
describe('throw if system is absent', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing system';
const absentSystem = absentValue;
const window: Partial<WindowVariables> = {
system: absentSystem,
};
// act
const act = () => getWindowInjectedSystemOperations(window);
// assert
expect(act).to.throw(expectedError);
});
});
it('returns from window', () => {
// arrange
const expectedValue = new SystemOperationsStub();
const window: Partial<WindowVariables> = {
system: expectedValue,
};
// act
const actualValue = getWindowInjectedSystemOperations(window);
// assert
expect(actualValue).to.equal(expectedValue);
});
});
});